diff --git a/client-mobile/.gradle/8.2/checksums/checksums.lock b/client-mobile/.gradle/8.2/checksums/checksums.lock
new file mode 100644
index 0000000..4910ad8
Binary files /dev/null and b/client-mobile/.gradle/8.2/checksums/checksums.lock differ
diff --git a/client-mobile/.gradle/8.2/checksums/md5-checksums.bin b/client-mobile/.gradle/8.2/checksums/md5-checksums.bin
new file mode 100644
index 0000000..f0de2c2
Binary files /dev/null and b/client-mobile/.gradle/8.2/checksums/md5-checksums.bin differ
diff --git a/client-mobile/.gradle/8.2/checksums/sha1-checksums.bin b/client-mobile/.gradle/8.2/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..34817f0
Binary files /dev/null and b/client-mobile/.gradle/8.2/checksums/sha1-checksums.bin differ
diff --git a/client-mobile/.gradle/8.2/dependencies-accessors/dependencies-accessors.lock b/client-mobile/.gradle/8.2/dependencies-accessors/dependencies-accessors.lock
new file mode 100644
index 0000000..766a9e4
Binary files /dev/null and b/client-mobile/.gradle/8.2/dependencies-accessors/dependencies-accessors.lock differ
diff --git a/client-mobile/.gradle/8.2/dependencies-accessors/gc.properties b/client-mobile/.gradle/8.2/dependencies-accessors/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/client-mobile/.gradle/8.2/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.2/executionHistory/executionHistory.bin
new file mode 100644
index 0000000..85abcbd
Binary files /dev/null and b/client-mobile/.gradle/8.2/executionHistory/executionHistory.bin differ
diff --git a/client-mobile/.gradle/8.2/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.2/executionHistory/executionHistory.lock
new file mode 100644
index 0000000..387cb8a
Binary files /dev/null and b/client-mobile/.gradle/8.2/executionHistory/executionHistory.lock differ
diff --git a/client-mobile/.gradle/8.2/fileChanges/last-build.bin b/client-mobile/.gradle/8.2/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/client-mobile/.gradle/8.2/fileChanges/last-build.bin differ
diff --git a/client-mobile/.gradle/8.2/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.2/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..9474f9f
Binary files /dev/null and b/client-mobile/.gradle/8.2/fileHashes/fileHashes.bin differ
diff --git a/client-mobile/.gradle/8.2/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.2/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..ecda3c9
Binary files /dev/null and b/client-mobile/.gradle/8.2/fileHashes/fileHashes.lock differ
diff --git a/client-mobile/.gradle/8.2/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.2/fileHashes/resourceHashesCache.bin
new file mode 100644
index 0000000..dabeb0e
Binary files /dev/null and b/client-mobile/.gradle/8.2/fileHashes/resourceHashesCache.bin differ
diff --git a/client-mobile/.gradle/8.2/gc.properties b/client-mobile/.gradle/8.2/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/client-mobile/.gradle/8.5/checksums/checksums.lock b/client-mobile/.gradle/8.5/checksums/checksums.lock
new file mode 100644
index 0000000..b120717
Binary files /dev/null and b/client-mobile/.gradle/8.5/checksums/checksums.lock differ
diff --git a/client-mobile/.gradle/8.5/checksums/md5-checksums.bin b/client-mobile/.gradle/8.5/checksums/md5-checksums.bin
new file mode 100644
index 0000000..c143861
Binary files /dev/null and b/client-mobile/.gradle/8.5/checksums/md5-checksums.bin differ
diff --git a/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin b/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..541f4ea
Binary files /dev/null and b/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin differ
diff --git a/client-mobile/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock b/client-mobile/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock
new file mode 100644
index 0000000..1a3d324
Binary files /dev/null and b/client-mobile/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock differ
diff --git a/client-mobile/.gradle/8.5/dependencies-accessors/gc.properties b/client-mobile/.gradle/8.5/dependencies-accessors/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin
new file mode 100644
index 0000000..65ab3f5
Binary files /dev/null and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ
diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock
new file mode 100644
index 0000000..2c694d3
Binary files /dev/null and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ
diff --git a/client-mobile/.gradle/8.5/fileChanges/last-build.bin b/client-mobile/.gradle/8.5/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/client-mobile/.gradle/8.5/fileChanges/last-build.bin differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..6c4e318
Binary files /dev/null and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..ef26a51
Binary files /dev/null and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin
new file mode 100644
index 0000000..1ff57d5
Binary files /dev/null and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ
diff --git a/client-mobile/.gradle/8.5/gc.properties b/client-mobile/.gradle/8.5/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/client-mobile/.gradle/9.0.0/checksums/checksums.lock b/client-mobile/.gradle/9.0.0/checksums/checksums.lock
new file mode 100644
index 0000000..18ea0c6
Binary files /dev/null and b/client-mobile/.gradle/9.0.0/checksums/checksums.lock differ
diff --git a/client-mobile/.gradle/9.0.0/checksums/md5-checksums.bin b/client-mobile/.gradle/9.0.0/checksums/md5-checksums.bin
new file mode 100644
index 0000000..354264a
Binary files /dev/null and b/client-mobile/.gradle/9.0.0/checksums/md5-checksums.bin differ
diff --git a/client-mobile/.gradle/9.0.0/checksums/sha1-checksums.bin b/client-mobile/.gradle/9.0.0/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..9c41c00
Binary files /dev/null and b/client-mobile/.gradle/9.0.0/checksums/sha1-checksums.bin differ
diff --git a/client-mobile/.gradle/9.0.0/fileChanges/last-build.bin b/client-mobile/.gradle/9.0.0/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/client-mobile/.gradle/9.0.0/fileChanges/last-build.bin differ
diff --git a/client-mobile/.gradle/9.0.0/fileHashes/fileHashes.bin b/client-mobile/.gradle/9.0.0/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..79c0eeb
Binary files /dev/null and b/client-mobile/.gradle/9.0.0/fileHashes/fileHashes.bin differ
diff --git a/client-mobile/.gradle/9.0.0/fileHashes/fileHashes.lock b/client-mobile/.gradle/9.0.0/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..47f09ac
Binary files /dev/null and b/client-mobile/.gradle/9.0.0/fileHashes/fileHashes.lock differ
diff --git a/client-mobile/.gradle/9.0.0/gc.properties b/client-mobile/.gradle/9.0.0/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 0000000..0fbf02f
Binary files /dev/null and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/client-mobile/.gradle/buildOutputCleanup/cache.properties b/client-mobile/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 0000000..0ed8762
--- /dev/null
+++ b/client-mobile/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Tue Apr 14 00:27:15 MSK 2026
+gradle.version=8.5
diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin
new file mode 100644
index 0000000..e5582b0
Binary files /dev/null and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ
diff --git a/client-mobile/.gradle/config.properties b/client-mobile/.gradle/config.properties
new file mode 100644
index 0000000..a641649
--- /dev/null
+++ b/client-mobile/.gradle/config.properties
@@ -0,0 +1,2 @@
+#Tue Apr 14 00:13:58 MSK 2026
+java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
diff --git a/client-mobile/.gradle/vcs-1/gc.properties b/client-mobile/.gradle/vcs-1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/client-mobile/app/build.gradle.kts b/client-mobile/app/build.gradle.kts
new file mode 100644
index 0000000..6282594
--- /dev/null
+++ b/client-mobile/app/build.gradle.kts
@@ -0,0 +1,124 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.dagger.hilt.android")
+ id("com.google.gms.google-services")
+ kotlin("kapt")
+}
+
+android {
+ namespace = "ru.knot.messager"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "ru.knot.messager"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ // Подключаем все наши папки с кодом как sourceSets
+ sourceSets {
+ getByName("main") {
+ java.srcDirs(
+ "src/main/kotlin",
+ "../auth",
+ "../chats",
+ "../core",
+ "../calls",
+ "../stories",
+ "../contacts",
+ "../profiles",
+ "../settings",
+ "../navigation"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ // AndroidX & UI
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
+ implementation("androidx.activity:activity-compose:1.8.1")
+ implementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("com.google.android.material:material:1.11.0")
+ implementation("androidx.navigation:navigation-compose:2.7.5")
+ implementation("androidx.compose.material:material-icons-extended")
+
+ // Hilt
+ implementation("com.google.dagger:hilt-android:2.48")
+ kapt("com.google.dagger:hilt-android-compiler:2.48")
+ implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
+
+ // Network & SignalR
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.9.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ implementation("com.microsoft.signalr:signalr:7.0.0")
+
+ // WebRTC
+ implementation("com.github.webrtc-sdk:android:104.5112.01")
+
+ // Media3 (ExoPlayer)
+ implementation("androidx.media3:media3-exoplayer:1.2.0")
+ implementation("androidx.media3:media3-ui:1.2.0")
+ implementation("androidx.media3:media3-common:1.2.0")
+
+ // Images & GIF
+ implementation("io.coil-kt:coil-compose:2.5.0")
+ implementation("io.coil-kt:coil-gif:2.5.0")
+ implementation("io.coil-kt:coil-svg:2.5.0")
+
+ // Security
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+
+ // UCrop (Image Cropping)
+ implementation("com.github.yalantis:ucrop:2.2.8")
+
+ // Firebase (Push Notifications)
+ implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
+ implementation("com.google.firebase:firebase-messaging-ktx")
+ implementation("com.google.firebase:firebase-analytics-ktx")
+
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+}
diff --git a/client-mobile/app/google-services.json b/client-mobile/app/google-services.json
new file mode 100644
index 0000000..cbd9564
--- /dev/null
+++ b/client-mobile/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "483917912506",
+ "project_id": "knot-bad1a",
+ "storage_bucket": "knot-bad1a.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
+ "android_client_info": {
+ "package_name": "ru.knot.messager"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/client-mobile/app/src/main/AndroidManifest.xml b/client-mobile/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..853a047
--- /dev/null
+++ b/client-mobile/app/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt
new file mode 100644
index 0000000..160f4f2
--- /dev/null
+++ b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt
@@ -0,0 +1,29 @@
+package com.knot.messenger
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import dagger.hilt.android.AndroidEntryPoint
+import navigation.AppNavigation
+import core.presentation.theme.ForkMessengerTheme
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ ForkMessengerTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AppNavigation()
+ }
+ }
+ }
+ }
+}
diff --git a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt
new file mode 100644
index 0000000..04b1e02
--- /dev/null
+++ b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt
@@ -0,0 +1,7 @@
+package com.knot.messenger
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class MainApplication : Application()
diff --git a/client-mobile/app/src/main/res/values-en/strings.xml b/client-mobile/app/src/main/res/values-en/strings.xml
new file mode 100644
index 0000000..ef63089
--- /dev/null
+++ b/client-mobile/app/src/main/res/values-en/strings.xml
@@ -0,0 +1,56 @@
+
+ ForkMessenger
+ Login
+ Register
+ Username
+ Password
+ Display Name
+ Settings
+ Save
+ Back
+ Server Connection
+ API Base URL
+ Server Features
+ Stories
+ Polls
+ Calls
+ Groups
+ Enabled
+ Disabled
+ Limits
+ Max File Size
+ Max Group Members
+ Message
+ Call
+ Block
+ Profile
+ Confirm Password
+ Passwords do not match
+ Don\'t have an account? Register
+ Already have an account? Login
+ An error occurred
+ Loading...
+ Chats
+ Contacts
+ Stories
+ Create Story
+ Type a message...
+ Reply to %1$s...
+ STORY EDITOR
+ PUBLISH
+ START CREATION
+ TEXT
+ CROP
+ STICKERS
+ BRUSH
+ FILTERS
+ Remove
+ No chats found
+ typing...
+ Video Call
+ Emoji
+ Attach
+ Message...
+ Voice Message
+ Send
+
diff --git a/client-mobile/app/src/main/res/values/strings.xml b/client-mobile/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..c6d8b29
--- /dev/null
+++ b/client-mobile/app/src/main/res/values/strings.xml
@@ -0,0 +1,77 @@
+
+ ForkMessenger
+ Войти
+ Регистрация
+ Имя пользователя
+ Пароль
+ Отображаемое имя
+ Настройки
+ Сохранить
+ Назад
+ Подключение к серверу
+ API Base URL
+ Функции сервера
+ Истории
+ Опросы
+ Звонки
+ Группы
+ Включено
+ Отключено
+ Лимиты
+ Макс. размер файла
+ Макс. участников в группе
+ Сообщение
+ Позвонить
+ Заблокировать
+ Профиль
+ Подтвердите пароль
+ Пароли не совпадают
+ Нет аккаунта? Зарегистрироваться
+ Уже есть аккаунт? Войти
+ Произошла ошибка
+ Загрузка...
+ Чаты
+ Контакты
+ Истории
+ Создать историю
+ Напишите сообщение...
+ Ответить %1$s...
+ РЕДАКТОР ИСТОРИЙ
+ ОПУБЛИКОВАТЬ
+ НАЧАТЬ СОЗДАНИЕ
+ ТЕКСТ
+ ОБРЕЗКА
+ СТИКЕРЫ
+ КИСТЬ
+ ФИЛЬТРЫ
+ Удалить
+ Чаты не найдены
+ печатает...
+ Видеозвонок
+ Эмодзи
+ Прикрепить
+ Сообщение...
+ Голосовое сообщение
+ Отправить
+ Поиск...
+ В сети
+ Был(а): %1$s
+ недавно
+ Все
+ Онлайн
+ Заблокированные
+ Медиа
+ Уведомления
+ Без звука
+ Включить звук
+ Выйти из аккаунта
+ О себе
+ Редактировать профиль
+ Имя пользователя
+ Изменить фото
+ Отмена
+ Обрезать
+ Чаты
+ Контакты
+ Профиль
+
diff --git a/client-mobile/app/src/main/res/values/styles.xml b/client-mobile/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..3be2708
--- /dev/null
+++ b/client-mobile/app/src/main/res/values/styles.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/client-mobile/auth/data/remote/api/AuthApi.kt b/client-mobile/auth/data/remote/api/AuthApi.kt
new file mode 100644
index 0000000..14627fa
--- /dev/null
+++ b/client-mobile/auth/data/remote/api/AuthApi.kt
@@ -0,0 +1,22 @@
+package auth.data.remote.api
+
+import auth.data.remote.dto.AuthRequest
+import auth.data.remote.dto.AuthResponse
+import core.domain.model.ServerConfigModel
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+
+interface AuthApi {
+ @POST("auth/login")
+ suspend fun login(@Body request: AuthRequest): AuthResponse
+
+ @POST("auth/register")
+ suspend fun register(@Body request: AuthRequest): AuthResponse
+
+ @GET("config")
+ suspend fun getConfig(): ServerConfigModel
+
+ @POST("auth/push-token")
+ suspend fun updatePushToken(@Body token: String): Unit
+}
diff --git a/client-mobile/auth/data/remote/dto/AuthDtos.kt b/client-mobile/auth/data/remote/dto/AuthDtos.kt
new file mode 100644
index 0000000..869964a
--- /dev/null
+++ b/client-mobile/auth/data/remote/dto/AuthDtos.kt
@@ -0,0 +1,23 @@
+package auth.data.remote.dto
+
+import com.google.gson.annotations.SerializedName
+
+data class AuthRequest(
+ @SerializedName("userName") val userName: String,
+ @SerializedName("password") val password: String
+)
+
+data class AuthResponse(
+ @SerializedName("accessToken") val accessToken: String?,
+ @SerializedName("user") val user: UserDto?,
+ @SerializedName("userId") val userId: String?,
+ @SerializedName("username") val username: String?,
+ @SerializedName("displayName") val displayName: String?
+)
+
+data class UserDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("userName") val userName: String,
+ @SerializedName("displayName") val displayName: String?,
+ @SerializedName("avatarUrl") val avatarUrl: String?
+)
diff --git a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt
new file mode 100644
index 0000000..5f2d6c8
--- /dev/null
+++ b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt
@@ -0,0 +1,72 @@
+package auth.data.repository
+
+import auth.data.remote.api.AuthApi
+import auth.data.remote.dto.AuthRequest
+import auth.domain.model.AuthResult
+import auth.domain.repository.AuthRepository
+import core.network.ServerConfig
+import core.security.TokenManager
+import javax.inject.Inject
+
+class AuthRepositoryImpl @Inject constructor(
+ private val api: AuthApi,
+ private val tokenManager: TokenManager,
+ private val serverConfig: ServerConfig
+) : AuthRepository {
+
+ override suspend fun login(userName: String, password: String): Result {
+ return try {
+ val response = api.login(AuthRequest(userName, password))
+ val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
+
+ tokenManager.saveToken(token)
+ fetchConfig()
+ Result.success(
+ AuthResult(
+ token = token,
+ userId = response.userId ?: "",
+ userName = response.username ?: userName,
+ displayName = response.displayName ?: response.username ?: userName,
+ avatarUrl = null
+ )
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun register(userName: String, password: String): Result {
+ return try {
+ val response = api.register(AuthRequest(userName, password))
+ val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
+
+ tokenManager.saveToken(token)
+ fetchConfig()
+ Result.success(
+ AuthResult(
+ token = token,
+ userId = response.userId ?: "",
+ userName = response.username ?: userName,
+ displayName = response.displayName ?: response.username ?: userName,
+ avatarUrl = null
+ )
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun logout() {
+ tokenManager.deleteToken()
+ }
+
+ override suspend fun fetchConfig(): Result {
+ return try {
+ val config = api.getConfig()
+ serverConfig.saveServerConfig(config)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/client-mobile/auth/di/AuthModule.kt b/client-mobile/auth/di/AuthModule.kt
new file mode 100644
index 0000000..3c24d67
--- /dev/null
+++ b/client-mobile/auth/di/AuthModule.kt
@@ -0,0 +1,34 @@
+package auth.di
+
+import auth.data.remote.api.AuthApi
+import auth.data.repository.AuthRepositoryImpl
+import auth.domain.repository.AuthRepository
+import core.network.ServerConfig
+import core.security.TokenManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AuthModule {
+
+ @Provides
+ @Singleton
+ fun provideAuthApi(retrofit: Retrofit): AuthApi {
+ return retrofit.create(AuthApi::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideAuthRepository(
+ api: AuthApi,
+ tokenManager: TokenManager,
+ serverConfig: ServerConfig
+ ): AuthRepository {
+ return AuthRepositoryImpl(api, tokenManager, serverConfig)
+ }
+}
diff --git a/client-mobile/auth/domain/model/AuthModels.kt b/client-mobile/auth/domain/model/AuthModels.kt
new file mode 100644
index 0000000..45d28bd
--- /dev/null
+++ b/client-mobile/auth/domain/model/AuthModels.kt
@@ -0,0 +1,9 @@
+package auth.domain.model
+
+data class AuthResult(
+ val token: String,
+ val userId: String,
+ val userName: String,
+ val displayName: String,
+ val avatarUrl: String?
+)
diff --git a/client-mobile/auth/domain/repository/AuthRepository.kt b/client-mobile/auth/domain/repository/AuthRepository.kt
new file mode 100644
index 0000000..fbf8666
--- /dev/null
+++ b/client-mobile/auth/domain/repository/AuthRepository.kt
@@ -0,0 +1,10 @@
+package auth.domain.repository
+
+import auth.domain.model.AuthResult
+
+interface AuthRepository {
+ suspend fun login(userName: String, password: String): Result
+ suspend fun register(userName: String, password: String): Result
+ suspend fun logout()
+ suspend fun fetchConfig(): Result
+}
diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt
new file mode 100644
index 0000000..10feb5f
--- /dev/null
+++ b/client-mobile/auth/presentation/AuthViewModel.kt
@@ -0,0 +1,53 @@
+package auth.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import auth.domain.repository.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class AuthState(
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val isAuthenticated: Boolean = false
+)
+
+@HiltViewModel
+class AuthViewModel @Inject constructor(
+ private val repository: AuthRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(AuthState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun login(userName: String, password: String) {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true, error = null) }
+ repository.login(userName, password)
+ .onSuccess {
+ _state.update { it.copy(isLoading = false, isAuthenticated = true) }
+ }
+ .onFailure { e ->
+ _state.update { it.copy(isLoading = false, error = e.message) }
+ }
+ }
+ }
+
+ fun register(userName: String, password: String) {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true, error = null) }
+ repository.register(userName, password)
+ .onSuccess {
+ _state.update { it.copy(isLoading = false, isAuthenticated = true) }
+ }
+ .onFailure { e ->
+ _state.update { it.copy(isLoading = false, error = e.message) }
+ }
+ }
+ }
+}
diff --git a/client-mobile/auth/presentation/LoginScreen.kt b/client-mobile/auth/presentation/LoginScreen.kt
new file mode 100644
index 0000000..a9865bc
--- /dev/null
+++ b/client-mobile/auth/presentation/LoginScreen.kt
@@ -0,0 +1,95 @@
+package auth.presentation
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LoginScreen(
+ viewModel: AuthViewModel,
+ onNavigateToRegister: () -> Unit,
+ onNavigateToSettings: () -> Unit,
+ onLoginSuccess: () -> Unit
+) {
+ val state by viewModel.state.collectAsState()
+ var userName by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ LaunchedEffect(state.isAuthenticated) {
+ if (state.isAuthenticated) {
+ onLoginSuccess()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.login)) },
+ actions = {
+ IconButton(onClick = onNavigateToSettings) {
+ Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ OutlinedTextField(
+ value = userName,
+ onValueChange = { userName = it },
+ label = { Text(stringResource(R.string.username)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.password)) },
+ modifier = Modifier.fillMaxWidth(),
+ visualTransformation = PasswordVisualTransformation(),
+ singleLine = true
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (state.isLoading) {
+ CircularProgressIndicator()
+ } else {
+ Button(
+ onClick = { viewModel.login(userName, password) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = userName.isNotBlank() && password.isNotBlank()
+ ) {
+ Text(stringResource(R.string.login))
+ }
+ TextButton(onClick = onNavigateToRegister) {
+ Text(stringResource(R.string.no_account_register))
+ }
+ }
+
+ if (state.error != null) {
+ Text(
+ text = state.error!!,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/client-mobile/auth/presentation/RegisterScreen.kt b/client-mobile/auth/presentation/RegisterScreen.kt
new file mode 100644
index 0000000..61698f5
--- /dev/null
+++ b/client-mobile/auth/presentation/RegisterScreen.kt
@@ -0,0 +1,106 @@
+package auth.presentation
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RegisterScreen(
+ viewModel: AuthViewModel,
+ onNavigateToLogin: () -> Unit,
+ onRegisterSuccess: () -> Unit
+) {
+ val state by viewModel.state.collectAsState()
+ var userName by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var confirmPassword by remember { mutableStateOf("") }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
+
+ LaunchedEffect(state.isAuthenticated) {
+ if (state.isAuthenticated) {
+ onRegisterSuccess()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text(stringResource(R.string.register)) })
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ OutlinedTextField(
+ value = userName,
+ onValueChange = { userName = it },
+ label = { Text(stringResource(R.string.username)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.password)) },
+ modifier = Modifier.fillMaxWidth(),
+ visualTransformation = PasswordVisualTransformation(),
+ singleLine = true
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = confirmPassword,
+ onValueChange = { confirmPassword = it },
+ label = { Text(stringResource(R.string.confirm_password)) },
+ modifier = Modifier.fillMaxWidth(),
+ visualTransformation = PasswordVisualTransformation(),
+ singleLine = true
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (state.isLoading) {
+ CircularProgressIndicator()
+ } else {
+ Button(
+ onClick = {
+ if (password == confirmPassword) {
+ errorMessage = null
+ viewModel.register(userName, password)
+ } else {
+ errorMessage = passwordsNotMatchMsg
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
+ ) {
+ Text(stringResource(R.string.register))
+ }
+ TextButton(onClick = onNavigateToLogin) {
+ Text(stringResource(R.string.already_have_account))
+ }
+ }
+
+ val displayError = state.error ?: errorMessage
+ if (displayError != null) {
+ Text(
+ text = displayError,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/client-mobile/build.gradle.kts b/client-mobile/build.gradle.kts
new file mode 100644
index 0000000..70ac46c
--- /dev/null
+++ b/client-mobile/build.gradle.kts
@@ -0,0 +1,8 @@
+// Top-level build file
+plugins {
+ id("com.android.application") version "8.2.0" apply false
+ id("com.android.library") version "8.2.0" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.22" apply false
+ id("com.google.dagger.hilt.android") version "2.48" apply false
+ id("com.google.gms.google-services") version "4.4.0" apply false
+}
diff --git a/client-mobile/calls/data/remote/CallAudioManager.kt b/client-mobile/calls/data/remote/CallAudioManager.kt
new file mode 100644
index 0000000..6d8c01f
--- /dev/null
+++ b/client-mobile/calls/data/remote/CallAudioManager.kt
@@ -0,0 +1,43 @@
+package calls.data.remote
+
+import android.content.Context
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
+
+class CallAudioManager(private val context: Context) {
+ private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ private var originalMode: Int = AudioManager.MODE_NORMAL
+ private var originalIsSpeakerphoneOn: Boolean = false
+
+ fun startCallMode(isVideoCall: Boolean) {
+ originalMode = audioManager.mode
+ originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
+
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
+ setSpeakerphoneOn(isVideoCall)
+
+ // Запрашиваем фокус аудио
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val playbackAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+ val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+ .setAudioAttributes(playbackAttributes)
+ .build()
+ audioManager.requestAudioFocus(focusRequest)
+ }
+ }
+
+ fun setSpeakerphoneOn(on: Boolean) {
+ audioManager.isSpeakerphoneOn = on
+ }
+
+ fun stopCallMode() {
+ audioManager.mode = originalMode
+ audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
+ audioManager.abandonAudioFocus(null)
+ }
+}
diff --git a/client-mobile/calls/data/remote/GroupWebRtcManager.kt b/client-mobile/calls/data/remote/GroupWebRtcManager.kt
new file mode 100644
index 0000000..ecfdcb0
--- /dev/null
+++ b/client-mobile/calls/data/remote/GroupWebRtcManager.kt
@@ -0,0 +1,46 @@
+package calls.data.remote
+
+import android.content.Context
+import org.webrtc.*
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class GroupWebRtcManager @Inject constructor(private val context: Context) {
+ private val peerConnections = ConcurrentHashMap()
+ private val factory: PeerConnectionFactory by lazy { createFactory() }
+
+ private fun createFactory(): PeerConnectionFactory {
+ PeerConnectionFactory.initialize(
+ PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
+ )
+ return PeerConnectionFactory.builder()
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
+ .createPeerConnectionFactory()
+ }
+
+ fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
+ val iceServers = listOf(
+ PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
+ )
+ val pc = factory.createPeerConnection(iceServers, observer)
+ if (pc != null) {
+ peerConnections[userId] = pc
+ }
+ return pc
+ }
+
+ fun removeParticipant(userId: String) {
+ peerConnections[userId]?.dispose()
+ peerConnections.remove(userId)
+ }
+
+ fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
+
+ fun closeAll() {
+ peerConnections.values.forEach { it.dispose() }
+ peerConnections.clear()
+ }
+}
diff --git a/client-mobile/calls/data/remote/WebRtcManager.kt b/client-mobile/calls/data/remote/WebRtcManager.kt
new file mode 100644
index 0000000..5ca37ea
--- /dev/null
+++ b/client-mobile/calls/data/remote/WebRtcManager.kt
@@ -0,0 +1,50 @@
+package calls.data.remote
+
+import android.content.Context
+import org.webrtc.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WebRtcManager @Inject constructor(private val context: Context) {
+ private var peerConnection: PeerConnection? = null
+ private val factory: PeerConnectionFactory by lazy { createFactory() }
+
+ // Аудио и видео источники
+ private val videoSource by lazy { factory.createVideoSource(false) }
+ private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
+
+ private fun createFactory(): PeerConnectionFactory {
+ PeerConnectionFactory.initialize(
+ PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
+ )
+ return PeerConnectionFactory.builder()
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
+ .createPeerConnectionFactory()
+ }
+
+ fun initializePeerConnection(observer: PeerConnection.Observer) {
+ val iceServers = listOf(
+ PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
+ )
+ peerConnection = factory.createPeerConnection(iceServers, observer)
+ }
+
+ fun createOffer(observer: SdpObserver) {
+ peerConnection?.createOffer(observer, MediaConstraints())
+ }
+
+ fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
+ peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
+ }
+
+ fun addIceCandidate(candidate: IceCandidate) {
+ peerConnection?.addIceCandidate(candidate)
+ }
+
+ fun close() {
+ peerConnection?.dispose()
+ peerConnection = null
+ }
+}
diff --git a/client-mobile/calls/presentation/CallScreen.kt b/client-mobile/calls/presentation/CallScreen.kt
new file mode 100644
index 0000000..0505c90
--- /dev/null
+++ b/client-mobile/calls/presentation/CallScreen.kt
@@ -0,0 +1,148 @@
+package calls.presentation
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import core.presentation.components.AppAvatar
+import ru.knot.messager.R
+
+@Composable
+fun CallScreen(
+ viewModel: CallViewModel,
+ onBack: () -> Unit
+) {
+ val state by viewModel.state.collectAsState()
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color(0xFF0F0F10),
+ Color(0xFF161618),
+ Color(0xFF6366F1).copy(alpha = 0.2f)
+ )
+ )
+ )
+ ) {
+ // Контент звонка (Аватар или Видео)
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 100.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AppAvatar(
+ url = state.callerAvatar,
+ name = state.callerName,
+ size = 140.dp
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = state.callerName,
+ style = MaterialTheme.typography.headlineMedium,
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = when (state.status) {
+ CallStatus.INCOMING -> "Входящий звонок..."
+ CallStatus.OUTGOING -> "Вызов..."
+ CallStatus.CONNECTED -> "00:00" // TODO: Timer
+ CallStatus.ENDED -> "Звонок завершен"
+ else -> ""
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color.White.copy(alpha = 0.7f)
+ )
+ }
+
+ // Кнопки управления (Внизу)
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 60.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(32.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (state.status == CallStatus.INCOMING) {
+ // Кнопка отклонить
+ CallActionButton(
+ icon = Icons.Default.CallEnd,
+ backgroundColor = Color.Red,
+ onClick = { viewModel.endCall(); onBack() }
+ )
+ // Кнопка принять
+ CallActionButton(
+ icon = Icons.Default.Call,
+ backgroundColor = Color(0xFF10B981), // Green
+ onClick = { viewModel.acceptCall() }
+ )
+ } else {
+ // Стандартные кнопки во время разговора
+ IconButton(
+ onClick = { /* viewModel.toggleMic() */ },
+ modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
+ ) {
+ Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
+ }
+
+ CallActionButton(
+ icon = Icons.Default.CallEnd,
+ backgroundColor = Color.Red,
+ onClick = { viewModel.endCall(); onBack() }
+ )
+
+ IconButton(
+ onClick = { /* viewModel.toggleSpeaker() */ },
+ modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
+ ) {
+ Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CallActionButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ backgroundColor: Color,
+ onClick: () -> Unit
+) {
+ FloatingActionButton(
+ onClick = onClick,
+ containerColor = backgroundColor,
+ contentColor = Color.White,
+ shape = CircleShape,
+ modifier = Modifier.size(64.dp)
+ ) {
+ Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
+ }
+}
diff --git a/client-mobile/calls/presentation/CallViewModel.kt b/client-mobile/calls/presentation/CallViewModel.kt
new file mode 100644
index 0000000..8ba9fc8
--- /dev/null
+++ b/client-mobile/calls/presentation/CallViewModel.kt
@@ -0,0 +1,99 @@
+package calls.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import calls.data.remote.WebRtcManager
+import chats.data.remote.signalr.ChatEvent
+import chats.data.remote.signalr.ChatHubClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import org.webrtc.*
+import javax.inject.Inject
+
+enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
+
+data class CallState(
+ val status: CallStatus = CallStatus.IDLE,
+ val chatId: String? = null,
+ val callerName: String = "",
+ val callerAvatar: String? = null,
+ val isMuted: Boolean = false,
+ val isSpeakerOn: Boolean = false,
+ val localVideoTrack: VideoTrack? = null,
+ val remoteVideoTrack: VideoTrack? = null
+)
+
+@HiltViewModel
+class CallViewModel @Inject constructor(
+ private val webRtcManager: WebRtcManager,
+ private val signalrClient: ChatHubClient
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(CallState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ observeSignaling()
+ }
+
+ private fun observeSignaling() {
+ signalrClient.events
+ .onEach { event ->
+ when (event) {
+ is ChatEvent.CallIncoming -> onIncomingCall(event)
+ is ChatEvent.CallAnswered -> onCallAnswered(event)
+ is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
+ is ChatEvent.CallEnded -> onCallEnded()
+ else -> Unit
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+
+ private fun onIncomingCall(event: ChatEvent.CallIncoming) {
+ _state.update { it.copy(
+ status = CallStatus.INCOMING,
+ chatId = event.chatId,
+ callerName = "User ${event.from}" // TODO: Load actual user info
+ ) }
+ // Set remote description from offer
+ webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
+ override fun onCreateSuccess(p0: SessionDescription?) {}
+ override fun onSetSuccess() {}
+ override fun onCreateFailure(p0: String?) {}
+ override fun onSetFailure(p0: String?) {}
+ })
+ }
+
+ private fun onCallAnswered(event: ChatEvent.CallAnswered) {
+ _state.update { it.copy(status = CallStatus.CONNECTED) }
+ webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
+ override fun onCreateSuccess(p0: SessionDescription?) {}
+ override fun onSetSuccess() {}
+ override fun onCreateFailure(p0: String?) {}
+ override fun onSetFailure(p0: String?) {}
+ })
+ }
+
+ private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
+ // Parse candidate JSON and add to peer connection
+ // webRtcManager.addIceCandidate(...)
+ }
+
+ private fun onCallEnded() {
+ _state.update { it.copy(status = CallStatus.ENDED) }
+ webRtcManager.close()
+ }
+
+ fun acceptCall() {
+ val chatId = _state.value.chatId ?: return
+ // Create answer and send via SignalR
+ }
+
+ fun endCall() {
+ val chatId = _state.value.chatId ?: return
+ // Send call_end via SignalR
+ onCallEnded()
+ }
+}
diff --git a/client-mobile/calls/presentation/GroupCallViewModel.kt b/client-mobile/calls/presentation/GroupCallViewModel.kt
new file mode 100644
index 0000000..cd5ff8e
--- /dev/null
+++ b/client-mobile/calls/presentation/GroupCallViewModel.kt
@@ -0,0 +1,87 @@
+package calls.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import chats.data.remote.signalr.ChatHubClient
+import chats.data.remote.signalr.ChatEvent
+import calls.data.remote.GroupWebRtcManager
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import org.webrtc.*
+import javax.inject.Inject
+
+data class ParticipantState(
+ val userId: String,
+ val videoTrack: VideoTrack? = null,
+ val isAudioMuted: Boolean = false,
+ val isVideoDisabled: Boolean = false
+)
+
+data class GroupCallState(
+ val chatId: String? = null,
+ val participants: Map = emptyMap(),
+ val isMicEnabled: Boolean = true,
+ val isCameraEnabled: Boolean = true
+)
+
+@HiltViewModel
+class GroupCallViewModel @Inject constructor(
+ private val webRtcManager: GroupWebRtcManager,
+ private val signalrClient: ChatHubClient
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(GroupCallState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ observeSignalREvents()
+ }
+
+ private fun observeSignalREvents() {
+ signalrClient.events.onEach { event ->
+ when (event) {
+ is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
+ is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
+ is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
+ is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
+ // Дополнительные обработчики ICE кандидатов и т.д.
+ else -> Unit
+ }
+ }.launchIn(viewModelScope)
+ }
+
+ private fun handleUserJoined(userId: String) {
+ // Создаем PeerConnection для нового участника
+ // Логика идентична портированному CallModal.tsx
+ _state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
+ }
+
+ private fun handleUserLeft(userId: String) {
+ webRtcManager.removeParticipant(userId)
+ _state.update { it.copy(participants = it.participants - userId) }
+ }
+
+ private fun handleOffer(from: String, sdp: String) {
+ // Установка RemoteDescription и создание Answer
+ }
+
+ private fun handleAnswer(from: String, sdp: String) {
+ // Установка RemoteDescription
+ }
+
+ fun toggleMic() {
+ _state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
+ // Логика управления AudioTrack
+ }
+
+ fun toggleCamera() {
+ _state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
+ // Логика управления VideoTrack
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ webRtcManager.closeAll()
+ }
+}
diff --git a/client-mobile/calls/presentation/components/VideoGrid.kt b/client-mobile/calls/presentation/components/VideoGrid.kt
new file mode 100644
index 0000000..5d7fa6a
--- /dev/null
+++ b/client-mobile/calls/presentation/components/VideoGrid.kt
@@ -0,0 +1,67 @@
+package calls.presentation.components
+
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import org.webrtc.EglBase
+import org.webrtc.SurfaceViewRenderer
+import org.webrtc.VideoTrack
+
+@Composable
+fun VideoGrid(
+ participants: Map,
+ localVideoTrack: VideoTrack?
+) {
+ val eglBaseContext = remember { EglBase.create().eglBaseContext }
+ val allVideoTracks = remember(participants, localVideoTrack) {
+ listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
+ }
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(8.dp)
+ ) {
+ items(allVideoTracks) { track ->
+ Box(
+ modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth()
+ .aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
+ ) {
+ VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
+ }
+ }
+ }
+}
+
+@Composable
+fun VideoRenderer(
+ videoTrack: VideoTrack,
+ eglBaseContext: EglBase.Context
+) {
+ AndroidView(
+ factory = { context ->
+ SurfaceViewRenderer(context).apply {
+ init(eglBaseContext, null)
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ }
+ },
+ update = { view ->
+ videoTrack.addSink(view)
+ },
+ onRelease = { view ->
+ videoTrack.removeSink(view)
+ view.release()
+ }
+ )
+}
diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt
new file mode 100644
index 0000000..af98efd
--- /dev/null
+++ b/client-mobile/chats/data/remote/api/ChatApi.kt
@@ -0,0 +1,73 @@
+package chats.data.remote.api
+
+import chats.data.remote.dto.ChatDto
+import chats.data.remote.dto.MessageDto
+import retrofit2.http.*
+
+interface ChatApi {
+ @GET("chats")
+ suspend fun getChats(): List
+
+ @GET("messages/chat/{chatId}")
+ suspend fun getMessages(
+ @Path("chatId") chatId: String,
+ @Query("cursor") cursor: String? = null,
+ @Query("limit") limit: Int? = 50
+ ): List
+
+ @POST("messages/chat/{chatId}")
+ suspend fun sendMessage(@Path("chatId") chatId: String, @Body content: String): MessageDto
+
+ @Multipart
+ @POST("messages/upload")
+ suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
+
+ // Klipy GIF API
+ @GET("klipy/trending")
+ suspend fun getTrendingGifs(@Query("page") page: Int): List
+
+ @GET("klipy/search")
+ suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): List
+
+ @GET("klipy/categories")
+ suspend fun getGifCategories(): List
+
+ @POST("klipy/shared/{id}")
+ suspend fun markGifShared(@Path("id") id: String, @Body query: String)
+
+ @POST("messages/{messageId}/reactions")
+ suspend fun addReaction(@Path("messageId") messageId: String, @Body emoji: String)
+
+ @POST("chats/{chatId}/typing")
+ suspend fun sendTypingStatus(@Path("chatId") chatId: String)
+
+ @POST("chats/{chatId}/read")
+ suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
+}
+
+data class KlipyGifDto(
+ val id: String,
+ val images: GifImagesDto?,
+ val title: String?
+)
+
+data class GifImagesDto(
+ val fixed_height: GifImageSourceDto?,
+ val original: GifImageSourceDto?
+)
+
+data class GifImageSourceDto(
+ val url: String
+)
+
+data class GifCategoryDto(
+ val category: String,
+ val preview_url: String,
+ val query: String
+)
+
+data class FileUploadResponse(
+ val url: String,
+ val filename: String,
+ val size: Long
+)
diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt
new file mode 100644
index 0000000..c9fc0f7
--- /dev/null
+++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt
@@ -0,0 +1,36 @@
+package chats.data.remote.dto
+
+import com.google.gson.annotations.SerializedName
+
+data class UserBasicDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("userName") val userName: String,
+ @SerializedName("displayName") val displayName: String,
+ @SerializedName("avatarUrl") val avatarUrl: String?
+)
+
+data class MessageDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("chatId") val chatId: String,
+ @SerializedName("senderId") val senderId: String,
+ @SerializedName("content") val content: String?,
+ @SerializedName("type") val type: String,
+ @SerializedName("sequenceId") val sequenceId: Int,
+ @SerializedName("createdAt") val createdAt: String,
+ @SerializedName("sender") val sender: UserBasicDto,
+ @SerializedName("media") val media: List = emptyList()
+)
+
+data class MediaItemDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("type") val type: String,
+ @SerializedName("url") val url: String
+)
+
+data class ChatDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("type") val type: String,
+ @SerializedName("name") val name: String?,
+ @SerializedName("unreadCount") val unreadCount: Int,
+ @SerializedName("messages") val messages: List
+)
diff --git a/client-mobile/chats/data/remote/signalr/ChatEvent.kt b/client-mobile/chats/data/remote/signalr/ChatEvent.kt
new file mode 100644
index 0000000..664eb07
--- /dev/null
+++ b/client-mobile/chats/data/remote/signalr/ChatEvent.kt
@@ -0,0 +1,31 @@
+package chats.data.remote.signalr
+
+import chats.data.remote.dto.ChatDto
+import chats.data.remote.dto.MessageDto
+
+sealed class ChatEvent {
+ data class NewMessage(val message: MessageDto) : ChatEvent()
+ data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
+ data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
+ data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
+ data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
+ data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
+ data class UserOnline(val userId: String) : ChatEvent()
+ data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
+ data class NewChat(val chat: ChatDto) : ChatEvent()
+ data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String) : ChatEvent()
+
+ // Call Events (WebRTC Signaling)
+ data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
+ data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
+ data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
+ data class CallEnded(val chatId: String) : ChatEvent()
+
+ // Group Call Events
+ data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
+ data class GroupCallParticipants(val chatId: String, val participants: List) : ChatEvent()
+ data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
+ data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
+ data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
+ data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
+}
diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt
new file mode 100644
index 0000000..9bf6e74
--- /dev/null
+++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt
@@ -0,0 +1,86 @@
+package chats.data.remote.signalr
+
+import android.util.Log
+import io.reactivex.rxjava3.core.Single
+import com.microsoft.signalr.HubConnection
+import com.microsoft.signalr.HubConnectionBuilder
+import com.microsoft.signalr.HubConnectionState
+import chats.data.remote.dto.ChatDto
+import chats.data.remote.dto.MessageDto
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ChatHubClient @Inject constructor() {
+ private var hubConnection: HubConnection? = null
+ private val _events = MutableSharedFlow(extraBufferCapacity = 64)
+ val events: SharedFlow = _events.asSharedFlow()
+
+ fun connect(baseUrl: String, accessToken: String) {
+ if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
+
+ hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub")
+ .withAccessTokenProvider(Single.just(accessToken))
+ .build()
+
+ setupHandlers()
+
+ hubConnection?.onClosed { exception ->
+ Log.e("ChatHubClient", "Connection closed", exception)
+ }
+
+ hubConnection?.start()?.blockingAwait()
+ }
+
+ private fun setupHandlers() {
+ hubConnection?.let { conn ->
+ conn.on("new_message", { message: MessageDto ->
+ _events.tryEmit(ChatEvent.NewMessage(message))
+ }, MessageDto::class.java)
+
+ conn.on("messages_read", { chatId: String, userId: String, lastReadSequenceId: Int ->
+ _events.tryEmit(ChatEvent.MessagesRead(chatId, userId, lastReadSequenceId))
+ }, String::class.java, String::class.java, Int::class.java)
+
+ conn.on("user_typing", { chatId: String, userId: String ->
+ _events.tryEmit(ChatEvent.UserTyping(chatId, userId))
+ }, String::class.java, String::class.java)
+
+ conn.on("user_online", { userId: String ->
+ _events.tryEmit(ChatEvent.UserOnline(userId))
+ }, String::class.java)
+
+ conn.on("new_chat", { chat: ChatDto ->
+ _events.tryEmit(ChatEvent.NewChat(chat))
+ }, ChatDto::class.java)
+
+ conn.on("reaction_updated", { messageId: String, chatId: String, userId: String, emoji: String ->
+ _events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
+ }, String::class.java, String::class.java, String::class.java, String::class.java)
+
+ // WebRTC Signaling Handlers
+ conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
+ _events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
+ }, String::class.java, String::class.java, String::class.java, String::class.java)
+
+ conn.on("call_answered", { chatId: String, answer: String ->
+ _events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
+ }, String::class.java, String::class.java)
+
+ conn.on("ice_candidate", { chatId: String, candidate: String ->
+ _events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
+ }, String::class.java, String::class.java)
+
+ conn.on("call_ended", { chatId: String ->
+ _events.tryEmit(ChatEvent.CallEnded(chatId))
+ }, String::class.java)
+ }
+ }
+
+ fun disconnect() {
+ hubConnection?.stop()
+ }
+}
diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
new file mode 100644
index 0000000..c2e6574
--- /dev/null
+++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
@@ -0,0 +1,68 @@
+package chats.data.repository
+
+import chats.data.remote.api.ChatApi
+import chats.data.remote.dto.ChatDto
+import chats.data.remote.dto.MessageDto
+import chats.domain.model.Chat
+import chats.domain.model.Message
+import chats.domain.model.MediaType
+import chats.domain.repository.ChatRepository
+import javax.inject.Inject
+
+class ChatRepositoryImpl @Inject constructor(
+ private val api: ChatApi
+) : ChatRepository {
+
+ override suspend fun getChats(): List {
+ return api.getChats().map { it.toDomain() }
+ }
+
+ override suspend fun getMessages(chatId: String): List {
+ return api.getMessages(chatId).map { it.toDomain() }
+ }
+
+ override suspend fun sendMessage(chatId: String, content: String): Message {
+ return api.sendMessage(chatId, content).toDomain()
+ }
+
+ override suspend fun addReaction(messageId: String, emoji: String) {
+ api.addReaction(messageId, emoji)
+ }
+
+ override suspend fun sendTypingStatus(chatId: String) {
+ api.sendTypingStatus(chatId)
+ }
+
+ override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) {
+ api.markMessagesAsRead(chatId, lastMessageId)
+ }
+}
+
+// Mappers
+fun ChatDto.toDomain(): Chat = Chat(
+ id = id,
+ type = type,
+ name = name ?: "Unknown Chat",
+ avatar = null, // Update if avatar is added to ChatDto
+ unreadCount = unreadCount,
+ lastMessage = messages.firstOrNull()?.toDomain()
+)
+
+fun MessageDto.toDomain(): Message = Message(
+ id = id,
+ chatId = chatId,
+ senderId = senderId,
+ senderName = sender.displayName,
+ content = content,
+ sequenceId = sequenceId,
+ createdAt = createdAt,
+ media = media.map { it.url },
+ mediaUrl = media.firstOrNull()?.url,
+ mediaType = when (media.firstOrNull()?.type) {
+ "image" -> MediaType.IMAGE
+ "video" -> MediaType.VIDEO
+ "audio" -> MediaType.AUDIO
+ "file" -> MediaType.FILE
+ else -> MediaType.TEXT
+ }
+)
diff --git a/client-mobile/chats/di/ChatModule.kt b/client-mobile/chats/di/ChatModule.kt
new file mode 100644
index 0000000..d3a222a
--- /dev/null
+++ b/client-mobile/chats/di/ChatModule.kt
@@ -0,0 +1,35 @@
+package chats.di
+
+import chats.data.remote.api.ChatApi
+import chats.data.remote.signalr.ChatHubClient
+import chats.data.repository.ChatRepositoryImpl
+import chats.domain.repository.ChatRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ChatModule {
+
+ @Provides
+ @Singleton
+ fun provideChatApi(retrofit: Retrofit): ChatApi {
+ return retrofit.create(ChatApi::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideChatRepository(api: ChatApi): ChatRepository {
+ return ChatRepositoryImpl(api)
+ }
+
+ @Provides
+ @Singleton
+ fun provideChatHubClient(): ChatHubClient {
+ return ChatHubClient()
+ }
+}
diff --git a/client-mobile/chats/domain/model/ChatModels.kt b/client-mobile/chats/domain/model/ChatModels.kt
new file mode 100644
index 0000000..af9ae09
--- /dev/null
+++ b/client-mobile/chats/domain/model/ChatModels.kt
@@ -0,0 +1,10 @@
+package chats.domain.model
+
+data class Chat(
+ val id: String,
+ val type: String,
+ val name: String,
+ val avatar: String?,
+ val unreadCount: Int,
+ val lastMessage: Message?
+)
diff --git a/client-mobile/chats/domain/model/Message.kt b/client-mobile/chats/domain/model/Message.kt
new file mode 100644
index 0000000..63b55ce
--- /dev/null
+++ b/client-mobile/chats/domain/model/Message.kt
@@ -0,0 +1,21 @@
+package chats.domain.model
+
+data class Message(
+ val id: String,
+ val chatId: String,
+ val senderId: String,
+ val senderName: String,
+ val content: String?,
+ val sequenceId: Int,
+ val createdAt: String,
+ val media: List = emptyList(),
+ val mediaUrl: String? = null,
+ val mediaType: MediaType = MediaType.TEXT,
+ val reactions: Map = emptyMap(),
+ val isRead: Boolean = false,
+ val replyTo: Message? = null
+)
+
+enum class MediaType {
+ TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY
+}
diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt
new file mode 100644
index 0000000..13f9d5f
--- /dev/null
+++ b/client-mobile/chats/domain/repository/ChatRepository.kt
@@ -0,0 +1,13 @@
+package chats.domain.repository
+
+import chats.domain.model.Chat
+import chats.domain.model.Message
+
+interface ChatRepository {
+ suspend fun getChats(): List
+ suspend fun getMessages(chatId: String): List
+ suspend fun sendMessage(chatId: String, content: String): Message
+ suspend fun addReaction(messageId: String, emoji: String)
+ suspend fun sendTypingStatus(chatId: String)
+ suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
+}
diff --git a/client-mobile/chats/domain/usecase/UploadMediaUseCase.kt b/client-mobile/chats/domain/usecase/UploadMediaUseCase.kt
new file mode 100644
index 0000000..b7a879a
--- /dev/null
+++ b/client-mobile/chats/domain/usecase/UploadMediaUseCase.kt
@@ -0,0 +1,24 @@
+package chats.domain.usecase
+
+import chats.data.remote.api.ChatApi
+import chats.data.remote.api.FileUploadResponse
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import java.io.File
+import javax.inject.Inject
+
+class UploadMediaUseCase @Inject constructor(
+ private val api: ChatApi
+) {
+ suspend operator fun invoke(file: File): Result {
+ return try {
+ val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
+ val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
+ val response = api.uploadFile(body)
+ Result.success(response)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt
new file mode 100644
index 0000000..e791263
--- /dev/null
+++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt
@@ -0,0 +1,192 @@
+package chats.presentation.chat_detail
+
+import android.Manifest
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chats.presentation.components.EmojiPicker
+import chats.presentation.components.MessageBubble
+import core.utils.VoiceRecorder
+import core.utils.copyUriToFile
+import kotlinx.coroutines.launch
+import java.io.File
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatDetailScreen(
+ chatName: String,
+ viewModel: ChatDetailViewModel,
+ onBack: () -> Unit
+) {
+ val state by viewModel.state.collectAsState()
+ val context = LocalContext.current
+ val listState = rememberLazyListState()
+ val voiceRecorder = remember { VoiceRecorder(context) }
+ var textInput by remember { mutableStateOf("") }
+ var isEmojiPickerVisible by remember { mutableStateOf(false) }
+ var isRecording by remember { mutableStateOf(false) }
+
+ // Пикер галереи
+ val galleryLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ uri?.let {
+ val file = copyUriToFile(context, it)
+ file?.let { viewModel.uploadMedia(it) }
+ }
+ }
+
+ // Автопрокрутка к последнему сообщению
+ LaunchedEffect(state.messages.size) {
+ if (state.messages.isNotEmpty()) {
+ listState.animateScrollToItem(state.messages.size - 1)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text(chatName, style = MaterialTheme.typography.titleMedium)
+ if (state.isTyping) {
+ Text(
+ stringResource(R.string.typing),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ actions = {
+ if (state.canCall) {
+ IconButton(onClick = { /* Вызов (WebRTC) */ }) {
+ Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
+ }
+ IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
+ Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
+ }
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Список сообщений
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(state.messages) { message ->
+ MessageBubble(
+ message = message,
+ isCurrentUser = message.senderId == "CURRENT_USER_ID" // TODO: Get from Auth
+ )
+ }
+ }
+
+ if (state.isLoading) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ }
+
+ // Панель ввода
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) {
+ Icon(
+ Icons.Default.EmojiEmotions,
+ contentDescription = stringResource(R.string.emoji),
+ tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray
+ )
+ }
+ IconButton(onClick = { galleryLauncher.launch("*/*") }) {
+ Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
+ }
+
+ TextField(
+ value = textInput,
+ onValueChange = { textInput = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
+ maxLines = 4,
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ )
+ )
+
+ if (textInput.isBlank()) {
+ IconButton(
+ onClick = {
+ if (!isRecording) {
+ val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
+ voiceRecorder.startRecording(file)
+ isRecording = true
+ } else {
+ voiceRecorder.stopRecording()
+ isRecording = false
+ // TODO: Send voice file
+ }
+ }
+ ) {
+ Icon(
+ if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
+ contentDescription = stringResource(R.string.voice_message),
+ tint = if (isRecording) Color.Red else Color.Gray
+ )
+ }
+ } else {
+ IconButton(onClick = {
+ viewModel.sendMessage(textInput)
+ textInput = ""
+ }) {
+ Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
+ }
+ }
+ }
+
+ if (isEmojiPickerVisible) {
+ EmojiPicker(onEmojiSelected = { textInput += it })
+ }
+ }
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt
new file mode 100644
index 0000000..6083487
--- /dev/null
+++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt
@@ -0,0 +1,151 @@
+package chats.presentation.chat_detail
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import chats.data.remote.signalr.ChatEvent
+import chats.data.remote.signalr.ChatHubClient
+import chats.domain.model.Message
+import chats.domain.repository.ChatRepository
+import chats.data.repository.toDomain
+import core.network.ServerConfig
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+data class ChatDetailState(
+ val messages: List = emptyList(),
+ val isLoading: Boolean = false,
+ val isTyping: Boolean = false,
+ val typingUser: String? = null,
+ val error: String? = null,
+ val canCall: Boolean = true,
+ val maxFileSize: Long = 100 * 1024 * 1024
+)
+
+@HiltViewModel
+class ChatDetailViewModel @Inject constructor(
+ private val repository: ChatRepository,
+ private val signalrClient: ChatHubClient,
+ private val serverConfig: ServerConfig
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(ChatDetailState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var currentChatId: String? = null
+ private var typingTimerJob: Job? = null
+ private var lastTypingSentTime: Long = 0
+
+ init {
+ val config = serverConfig.getServerConfig()
+ _state.update { it.copy(
+ canCall = config.features.calls,
+ maxFileSize = config.limits.maxFileSize
+ ) }
+ }
+
+ fun setChatId(chatId: String) {
+ currentChatId = chatId
+ loadMessages(chatId)
+ observeSignalREvents()
+ }
+
+ fun loadMessages(chatId: String) {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true) }
+ try {
+ val messages = repository.getMessages(chatId)
+ _state.update { it.copy(messages = messages, isLoading = false) }
+ } catch (e: Exception) {
+ _state.update { it.copy(isLoading = false, error = e.localizedMessage) }
+ }
+ }
+ }
+
+ private fun observeSignalREvents() {
+ signalrClient.events
+ .filter { event ->
+ when(event) {
+ is ChatEvent.NewMessage -> event.message.chatId == currentChatId
+ is ChatEvent.ReactionUpdated -> event.chatId == currentChatId
+ is ChatEvent.UserTyping -> event.chatId == currentChatId
+ else -> false
+ }
+ }
+ .onEach { event ->
+ when (event) {
+ is ChatEvent.NewMessage -> {
+ // Avoid adding duplicates if already loaded
+ _state.update { s ->
+ val domainMsg = event.message.toDomain()
+ if (s.messages.none { it.id == domainMsg.id }) {
+ s.copy(messages = s.messages + domainMsg)
+ } else s
+ }
+ }
+ is ChatEvent.ReactionUpdated -> {
+ updateMessageReaction(event.messageId, event.userId, event.emoji)
+ }
+ is ChatEvent.UserTyping -> {
+ _state.update { it.copy(isTyping = true) }
+ // Reset typing status after some delay would be better,
+ // but usually server sends stopped_typing event.
+ }
+ else -> Unit
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+
+ private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
+ _state.update { s ->
+ val updatedMessages = s.messages.map { msg ->
+ if (msg.id == messageId) {
+ // Logic to update reactions map.
+ // Note: Simplified logic, usually we need to know if it was added or removed.
+ // If we assume reaction_updated is a toggle:
+ val currentReactions = msg.reactions.toMutableMap()
+ val count = currentReactions[emoji] ?: 0
+ // This is a placeholder logic as the exact behavior depends on server implementation.
+ // For now, let's just increment/decrement based on some convention or just refresh.
+ currentReactions[emoji] = count + 1
+ msg.copy(reactions = currentReactions)
+ } else msg
+ }
+ s.copy(messages = updatedMessages)
+ }
+ }
+
+ fun sendMessage(text: String) {
+ val chatId = currentChatId ?: return
+ viewModelScope.launch {
+ try {
+ repository.sendMessage(chatId, text)
+ } catch (e: Exception) {
+ _state.update { it.copy(error = e.localizedMessage) }
+ }
+ }
+ }
+
+ fun addReaction(messageId: String, emoji: String) {
+ viewModelScope.launch {
+ try {
+ repository.addReaction(messageId, emoji)
+ } catch (e: Exception) {
+ // Ignore
+ }
+ }
+ }
+
+ fun uploadMedia(file: File) {
+ if (file.length() > _state.value.maxFileSize) {
+ _state.update { it.copy(error = "File too large") }
+ return
+ }
+ // Upload logic...
+ }
+}
diff --git a/client-mobile/chats/presentation/chat_list/ChatListScreen.kt b/client-mobile/chats/presentation/chat_list/ChatListScreen.kt
new file mode 100644
index 0000000..f4cd081
--- /dev/null
+++ b/client-mobile/chats/presentation/chat_list/ChatListScreen.kt
@@ -0,0 +1,120 @@
+package chats.presentation.chat_list
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chats.presentation.components.ChatItem
+import stories.presentation.StoryViewModel
+import stories.presentation.components.StoryThumbnail
+import ru.knot.messager.R
+
+@Composable
+fun HorizontalDividerComponent(
+ modifier: Modifier = Modifier,
+ thickness: androidx.compose.ui.unit.Dp = 1.dp,
+ color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
+) {
+ androidx.compose.material3.Divider(
+ modifier = modifier,
+ thickness = thickness,
+ color = color
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatListScreen(
+ viewModel: ChatListViewModel,
+ storyViewModel: StoryViewModel,
+ onChatClick: (String) -> Unit,
+ onStoryClick: (Int) -> Unit
+) {
+ val state by viewModel.state.collectAsState()
+ val storyState by storyViewModel.state.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.chats_title)) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ )
+ }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ if (state.isLoading && state.chats.isEmpty()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ } else {
+ LazyColumn {
+ // Ряд историй сверху списка чатов
+ item {
+ if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ contentPadding = PaddingValues(horizontal = 8.dp)
+ ) {
+ items(storyState.storyGroups.size) { index ->
+ val group = storyState.storyGroups[index]
+ StoryThumbnail(
+ username = group.username,
+ avatarUrl = group.avatar,
+ hasUnseen = true, // В идеале проверяем по статусам
+ onClick = { onStoryClick(index) }
+ )
+ }
+ }
+ HorizontalDividerComponent(
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+
+ if (state.error != null && state.chats.isEmpty()) {
+ item {
+ Text(
+ text = "${stringResource(R.string.error_occurred)}: ${state.error}",
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ } else if (state.chats.isEmpty() && !state.isLoading) {
+ item {
+ Box(modifier = Modifier.fillParentMaxSize()) {
+ Text(
+ text = stringResource(R.string.no_chats_found),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+ } else {
+ items(state.chats) { chat ->
+ ChatItem(chat = chat, onClick = onChatClick)
+ HorizontalDividerComponent(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt
new file mode 100644
index 0000000..ce88bbf
--- /dev/null
+++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt
@@ -0,0 +1,86 @@
+package chats.presentation.chat_list
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import chats.domain.model.Chat
+import chats.domain.repository.ChatRepository
+import chats.data.remote.signalr.ChatHubClient
+import chats.data.remote.signalr.ChatEvent
+import core.network.ServerConfig
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import chats.data.repository.toDomain
+
+data class ChatListState(
+ val chats: List = emptyList(),
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val isStoriesEnabled: Boolean = true
+)
+
+@HiltViewModel
+class ChatListViewModel @Inject constructor(
+ private val repository: ChatRepository,
+ private val signalrClient: ChatHubClient,
+ private val serverConfig: ServerConfig
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(ChatListState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ val isStoriesEnabled = try {
+ serverConfig.getServerConfig().features.stories
+ } catch (e: Exception) {
+ true
+ }
+ _state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
+ loadChats()
+ observeSignalREvents()
+ }
+
+ fun loadChats() {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true) }
+ try {
+ val chats = repository.getChats()
+ _state.update { it.copy(chats = chats, isLoading = false) }
+ } catch (e: Exception) {
+ _state.update { it.copy(isLoading = false, error = e.message) }
+ }
+ }
+ }
+
+ private fun observeSignalREvents() {
+ signalrClient.events
+ .onEach { event ->
+ when (event) {
+ is ChatEvent.NewMessage -> {
+ updateChatsWithNewMessage(event)
+ }
+ is ChatEvent.NewChat -> {
+ _state.update { it.copy(chats = listOf(event.chat.toDomain()) + it.chats) }
+ }
+ else -> Unit
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+
+ private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
+ _state.update { currentState ->
+ val updatedChats = currentState.chats.map { chat ->
+ if (chat.id == event.message.chatId) {
+ chat.copy(
+ lastMessage = event.message.toDomain(),
+ unreadCount = chat.unreadCount + 1
+ )
+ } else chat
+ }.sortedByDescending { it.lastMessage?.createdAt }
+
+ currentState.copy(chats = updatedChats)
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/components/ChatItem.kt b/client-mobile/chats/presentation/components/ChatItem.kt
new file mode 100644
index 0000000..621559d
--- /dev/null
+++ b/client-mobile/chats/presentation/components/ChatItem.kt
@@ -0,0 +1,100 @@
+package chats.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chats.domain.model.Chat
+
+@Composable
+fun ChatItem(
+ chat: Chat,
+ onClick: (String) -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick(chat.id) }
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Заглушка аватара (в реальном приложении используем Coil для URL)
+ Box(
+ modifier = Modifier
+ .size(50.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = chat.name.take(1).uppercase(),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = chat.name,
+ style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ chat.lastMessage?.let {
+ Text(
+ text = it.createdAt.takeLast(5), // Упрощенный формат времени
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.Gray
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = chat.lastMessage?.content ?: "No messages yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.Gray,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+
+ if (chat.unreadCount > 0) {
+ Box(
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape)
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = chat.unreadCount.toString(),
+ color = Color.White,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/components/EmojiPicker.kt b/client-mobile/chats/presentation/components/EmojiPicker.kt
new file mode 100644
index 0000000..9ba6823
--- /dev/null
+++ b/client-mobile/chats/presentation/components/EmojiPicker.kt
@@ -0,0 +1,39 @@
+package chats.presentation.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun EmojiPicker(
+ onEmojiSelected: (String) -> Unit
+) {
+ val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "✨") // Базовый набор
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(6),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .padding(8.dp)
+ ) {
+ items(emojis) { emoji ->
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clickable { onEmojiSelected(emoji) },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = emoji, fontSize = 24.sp)
+ }
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/components/KlipyPicker.kt b/client-mobile/chats/presentation/components/KlipyPicker.kt
new file mode 100644
index 0000000..5a47959
--- /dev/null
+++ b/client-mobile/chats/presentation/components/KlipyPicker.kt
@@ -0,0 +1,84 @@
+package chats.presentation.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import coil.decode.GifDecoder
+import coil.decode.ImageDecoderDecoder
+import coil.request.ImageRequest
+import chats.data.remote.api.KlipyGifDto
+
+@Composable
+fun KlipyPicker(
+ gifs: List,
+ isLoading: Boolean,
+ onGifSelected: (String) -> Unit,
+ onSearch: (String) -> Unit
+) {
+ var query by remember { mutableStateOf("") }
+ val context = LocalContext.current
+ val imageLoader = coil.ImageLoader.Builder(context)
+ .components {
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ add(ImageDecoderDecoder.Factory())
+ } else {
+ add(GifDecoder.Factory())
+ }
+ }
+ .build()
+
+ Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = {
+ query = it
+ onSearch(it)
+ },
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ placeholder = { Text("Search GIFs...") },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ singleLine = true
+ )
+
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(4.dp)
+ ) {
+ items(gifs) { gif ->
+ val url = gif.images?.fixed_height?.url ?: ""
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(url)
+ .crossfade(true)
+ .build(),
+ imageLoader = imageLoader,
+ contentDescription = gif.title,
+ modifier = Modifier
+ .padding(4.dp)
+ .aspectRatio(1.5f)
+ .clickable { onGifSelected(url) },
+ contentScale = ContentScale.Crop
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/components/LinkPreview.kt b/client-mobile/chats/presentation/components/LinkPreview.kt
new file mode 100644
index 0000000..4b367d5
--- /dev/null
+++ b/client-mobile/chats/presentation/components/LinkPreview.kt
@@ -0,0 +1,78 @@
+package chats.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import core.utils.LinkMetadata
+
+@Composable
+fun LinkPreview(
+ metadata: LinkMetadata,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(contentColor.copy(alpha = 0.05f))
+ .clickable(onClick = onClick)
+ .padding(8.dp)
+ ) {
+ if (metadata.imageUrl != null) {
+ AsyncImage(
+ model = metadata.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(140.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ metadata.title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ color = contentColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ metadata.description?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = contentColor.copy(alpha = 0.8f),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 16.sp
+ )
+ }
+
+ Text(
+ text = metadata.url.removePrefix("https://").removePrefix("http://").split("/")[0],
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+}
diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt
new file mode 100644
index 0000000..f3b3575
--- /dev/null
+++ b/client-mobile/chats/presentation/components/MessageBubble.kt
@@ -0,0 +1,208 @@
+package chats.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import chats.domain.model.Message
+import chats.domain.model.MediaType
+import coil.compose.AsyncImage
+import core.presentation.components.AppVideoPlayer
+import core.presentation.components.AppAudioPlayer
+
+import chats.presentation.components.LinkPreview
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.platform.LocalContext
+
+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
+@Composable
+fun MessageBubble(
+ message: Message,
+ isCurrentUser: Boolean,
+ onReactionClick: (String) -> Unit = {}
+) {
+ var showReactionPicker by remember { mutableStateOf(false) }
+
+ val backgroundColor = if (isCurrentUser) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+
+ val contentColor = if (isCurrentUser) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
+ val shape = if (isCurrentUser) {
+ RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
+ } else {
+ RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
+ }
+
+ val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
+ (message.content == null || message.content.isEmpty())
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ contentAlignment = alignment
+ ) {
+ Column(
+ modifier = Modifier
+ .clip(shape)
+ .background(backgroundColor)
+ .combinedClickable(
+ onClick = { /* Handle normal click */ },
+ onLongClick = { showReactionPicker = true }
+ )
+ .padding(8.dp)
+ .widthIn(max = 300.dp)
+ ) {
+ if (showReactionPicker) {
+ Popup(
+ alignment = Alignment.TopCenter,
+ offset = IntOffset(0, -100),
+ onDismissRequest = { showReactionPicker = false }
+ ) {
+ ReactionPicker(onReactionSelected = {
+ onReactionClick(it)
+ showReactionPicker = false
+ })
+ }
+ }
+ // Sender Name (only for others)
+ if (!isCurrentUser) {
+ Text(
+ text = message.senderName,
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor.copy(alpha = 0.7f),
+ modifier = Modifier.padding(bottom = 2.dp)
+ )
+ }
+
+ // Reply Info
+ message.replyTo?.let { reply ->
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(contentColor.copy(alpha = 0.1f))
+ .padding(8.dp)
+ ) {
+ Column {
+ Text(
+ text = reply.senderName,
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor,
+ maxLines = 1
+ )
+ Text(
+ text = reply.content ?: "[Media]",
+ style = MaterialTheme.typography.bodySmall,
+ color = contentColor.copy(alpha = 0.7f),
+ maxLines = 1
+ )
+ }
+ }
+ }
+
+ // Media Content
+ if (message.mediaUrl != null) {
+ when (message.mediaType) {
+ MediaType.IMAGE -> {
+ AsyncImage(
+ model = message.mediaUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 200.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ MediaType.VIDEO -> {
+ AppVideoPlayer(
+ url = message.mediaUrl,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ useController = true,
+ autoPlay = false
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ MediaType.AUDIO -> {
+ AppAudioPlayer(
+ url = message.mediaUrl,
+ isVoiceMessage = isVoiceMessage,
+ contentColor = contentColor
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ else -> {}
+ }
+ }
+
+ // Text Content (Story reply or simple text)
+ if (message.mediaType == MediaType.STORY_REPLY) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(contentColor.copy(alpha = 0.1f))
+ .padding(8.dp)
+ ) {
+ Text("Story Reply: ${message.content}", color = contentColor, style = MaterialTheme.typography.bodyMedium)
+ }
+ } else if (!message.content.isNullOrBlank() && !isVoiceMessage) {
+ Text(
+ text = message.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = contentColor
+ )
+ }
+
+ // Time and Status
+ Row(
+ modifier = Modifier.align(Alignment.End),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = message.createdAt.takeLast(5),
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor.copy(alpha = 0.6f)
+ )
+ }
+
+ // Reactions
+ if (message.reactions.isNotEmpty()) {
+ MessageReactions(
+ reactions = message.reactions,
+ onReactionClick = onReactionClick
+ )
+ }
+ }
+ }
+}
diff --git a/client-mobile/chats/presentation/components/MessageReactions.kt b/client-mobile/chats/presentation/components/MessageReactions.kt
new file mode 100644
index 0000000..f0c2308
--- /dev/null
+++ b/client-mobile/chats/presentation/components/MessageReactions.kt
@@ -0,0 +1,61 @@
+package chats.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun ReactionPicker(
+ onReactionSelected: (String) -> Unit
+) {
+ val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
+
+ Row(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surface, CircleShape)
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ reactions.forEach { reaction ->
+ Text(
+ text = reaction,
+ modifier = Modifier
+ .size(32.dp)
+ .clickable { onReactionSelected(reaction) },
+ fontSize = 20.sp
+ )
+ }
+ }
+}
+
+@Composable
+fun MessageReactions(
+ reactions: Map, // Эмодзи -> Количество
+ onReactionClick: (String) -> Unit
+) {
+ Row(
+ modifier = Modifier.padding(top = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ reactions.forEach { (emoji, count) ->
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
+ .clickable { onReactionClick(emoji) }
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(text = "$emoji $count", fontSize = 12.sp)
+ }
+ }
+ }
+}
diff --git a/client-mobile/contacts/data/remote/api/ContactApi.kt b/client-mobile/contacts/data/remote/api/ContactApi.kt
new file mode 100644
index 0000000..21905d4
--- /dev/null
+++ b/client-mobile/contacts/data/remote/api/ContactApi.kt
@@ -0,0 +1,26 @@
+package contacts.data.remote.api
+
+import retrofit2.http.*
+
+interface ContactApi {
+ @GET("contacts")
+ suspend fun getContacts(): List
+
+ @POST("contacts/add")
+ suspend fun addContact(@Body userId: String): ContactDto
+
+ @DELETE("contacts/{userId}")
+ suspend fun removeContact(@Path("userId") userId: String)
+
+ @GET("users/search")
+ suspend fun searchUsers(@Query("query") query: String): List
+}
+
+data class ContactDto(
+ val id: String,
+ val username: String,
+ val displayName: String?,
+ val avatarUrl: String?,
+ val isOnline: Boolean,
+ val lastSeen: String?
+)
diff --git a/client-mobile/contacts/presentation/ContactListScreen.kt b/client-mobile/contacts/presentation/ContactListScreen.kt
new file mode 100644
index 0000000..dad5996
--- /dev/null
+++ b/client-mobile/contacts/presentation/ContactListScreen.kt
@@ -0,0 +1,130 @@
+package contacts.presentation
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import contacts.data.remote.api.ContactDto
+import core.presentation.components.AppAvatar
+import core.presentation.theme.SoftSquareShape
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ContactListScreen(
+ contacts: List,
+ isLoading: Boolean = false,
+ onContactClick: (String) -> Unit,
+ onSearchChange: (String) -> Unit
+) {
+ var searchQuery by remember { mutableStateOf("") }
+ var selectedTab by remember { mutableIntStateOf(0) }
+ val tabs = listOf(
+ stringResource(R.string.all),
+ stringResource(R.string.online_tab)
+ )
+
+ Scaffold(
+ topBar = {
+ Column {
+ TopAppBar(
+ title = { Text(stringResource(R.string.contacts_title)) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ )
+ TabRow(selectedTabIndex = selectedTab) {
+ tabs.forEachIndexed { index, title ->
+ Tab(
+ selected = selectedTab == index,
+ onClick = { selectedTab = index },
+ text = { Text(title) }
+ )
+ }
+ }
+ }
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Поисковая строка
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = {
+ searchQuery = it
+ onSearchChange(it)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ placeholder = { Text(stringResource(R.string.search_hint)) },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ shape = SoftSquareShape
+ )
+
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ val filteredContacts = contacts.filter {
+ if (selectedTab == 1) it.isOnline else true
+ }
+
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(filteredContacts) { contact ->
+ ContactItem(contact = contact, onClick = { onContactClick(contact.id) })
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(16.dp, 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AppAvatar(
+ url = contact.avatarUrl,
+ name = contact.username,
+ size = 56.dp
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = contact.displayName ?: contact.username,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ val statusText = if (contact.isOnline) {
+ stringResource(R.string.online)
+ } else {
+ stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
+ }
+ Text(
+ text = statusText,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/client-mobile/contacts/presentation/ContactListViewModel.kt b/client-mobile/contacts/presentation/ContactListViewModel.kt
new file mode 100644
index 0000000..a357e15
--- /dev/null
+++ b/client-mobile/contacts/presentation/ContactListViewModel.kt
@@ -0,0 +1,26 @@
+package contacts.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import contacts.data.remote.api.ContactDto
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+
+data class ContactListState(
+ val contacts: List = emptyList(),
+ val isLoading: Boolean = false,
+ val error: String? = null
+)
+
+@HiltViewModel
+class ContactListViewModel @Inject constructor() : ViewModel() {
+ private val _state = MutableStateFlow(ContactListState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun onSearchChange(query: String) {
+ // Заглушка поиска
+ }
+}
diff --git a/client-mobile/core/di/AppModule.kt b/client-mobile/core/di/AppModule.kt
new file mode 100644
index 0000000..c8f94c2
--- /dev/null
+++ b/client-mobile/core/di/AppModule.kt
@@ -0,0 +1,27 @@
+package core.di
+
+import android.content.Context
+import com.google.gson.Gson
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideContext(@ApplicationContext context: Context): Context {
+ return context
+ }
+
+ @Provides
+ @Singleton
+ fun provideGson(): Gson {
+ return Gson()
+ }
+}
diff --git a/client-mobile/core/di/NetworkModule.kt b/client-mobile/core/di/NetworkModule.kt
new file mode 100644
index 0000000..ce803f0
--- /dev/null
+++ b/client-mobile/core/di/NetworkModule.kt
@@ -0,0 +1,45 @@
+package core.di
+
+import core.network.AuthInterceptor
+import core.network.DynamicBaseUrlInterceptor
+import core.network.ServerConfig
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ authInterceptor: AuthInterceptor,
+ dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
+ ): OkHttpClient {
+ val logging = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ return OkHttpClient.Builder()
+ .addInterceptor(logging)
+ .addInterceptor(dynamicBaseUrlInterceptor)
+ .addInterceptor(authInterceptor)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl("https://api.placeholder.com/")
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+}
diff --git a/client-mobile/core/domain/model/ServerConfigModel.kt b/client-mobile/core/domain/model/ServerConfigModel.kt
new file mode 100644
index 0000000..d327d0d
--- /dev/null
+++ b/client-mobile/core/domain/model/ServerConfigModel.kt
@@ -0,0 +1,20 @@
+package core.domain.model
+
+import com.google.gson.annotations.SerializedName
+
+data class ServerConfigModel(
+ @SerializedName("features") val features: FeaturesConfig = FeaturesConfig(),
+ @SerializedName("limits") val limits: LimitsConfig = LimitsConfig()
+)
+
+data class FeaturesConfig(
+ @SerializedName("stories") val stories: Boolean = true,
+ @SerializedName("polls") val polls: Boolean = true,
+ @SerializedName("calls") val calls: Boolean = true,
+ @SerializedName("groups") val groups: Boolean = true
+)
+
+data class LimitsConfig(
+ @SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024, // 100MB default
+ @SerializedName("maxGroupMembers") val maxGroupMembers: Int = 200
+)
diff --git a/client-mobile/core/network/AuthInterceptor.kt b/client-mobile/core/network/AuthInterceptor.kt
new file mode 100644
index 0000000..9a0a569
--- /dev/null
+++ b/client-mobile/core/network/AuthInterceptor.kt
@@ -0,0 +1,22 @@
+package core.network
+
+import core.security.TokenManager
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthInterceptor @Inject constructor(
+ private val tokenManager: TokenManager
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val token = tokenManager.getToken()
+ val request = chain.request().newBuilder().apply {
+ token?.let {
+ addHeader("Authorization", "Bearer $it")
+ }
+ }.build()
+ return chain.proceed(request)
+ }
+}
diff --git a/client-mobile/core/network/DynamicBaseUrlInterceptor.kt b/client-mobile/core/network/DynamicBaseUrlInterceptor.kt
new file mode 100644
index 0000000..4456713
--- /dev/null
+++ b/client-mobile/core/network/DynamicBaseUrlInterceptor.kt
@@ -0,0 +1,46 @@
+package core.network
+
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DynamicBaseUrlInterceptor @Inject constructor(
+ private val serverConfig: ServerConfig
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val newBaseUrlString = serverConfig.getBaseUrl()
+ val newBaseUrl = newBaseUrlString.toHttpUrlOrNull() ?: return chain.proceed(originalRequest)
+
+ val oldUrl = originalRequest.url
+
+ // Формируем новый URL.
+ // Retrofit-пути у нас начинаются БЕЗ слеша (например "auth/login")
+ // Поэтому мы просто берем путь из настроек и добавляем к нему путь запроса.
+ val newUrl = newBaseUrl.newBuilder().apply {
+ val pathSegments = oldUrl.pathSegments
+ // Добавляем сегменты пути запроса к базовому пути из настроек
+ pathSegments.forEach { segment ->
+ if (segment.isNotEmpty()) {
+ addEncodedPathSegment(segment)
+ }
+ }
+ // Копируем параметры запроса (query params)
+ for (i in 0 until oldUrl.querySize) {
+ addEncodedQueryParameter(
+ oldUrl.queryParameterName(i),
+ oldUrl.queryParameterValue(i)
+ )
+ }
+ }.build()
+
+ val newRequest = originalRequest.newBuilder()
+ .url(newUrl)
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+}
diff --git a/client-mobile/core/network/ServerConfig.kt b/client-mobile/core/network/ServerConfig.kt
new file mode 100644
index 0000000..ded9527
--- /dev/null
+++ b/client-mobile/core/network/ServerConfig.kt
@@ -0,0 +1,49 @@
+package core.network
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.google.gson.Gson
+import core.domain.model.FeaturesConfig
+import core.domain.model.ServerConfigModel
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ServerConfig @Inject constructor(
+ private val context: Context,
+ private val gson: Gson
+) {
+ private val prefs: SharedPreferences = context.getSharedPreferences("server_settings", Context.MODE_PRIVATE)
+
+ companion object {
+ const val DEFAULT_BASE_URL = "https://your-api.com/api/"
+ private const val KEY_BASE_URL = "base_url"
+ private const val KEY_CONFIG_DATA = "config_data"
+ }
+
+ fun getBaseUrl(): String {
+ return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL
+ }
+
+ fun setBaseUrl(url: String) {
+ val formattedUrl = if (url.endsWith("/")) url else "$url/"
+ prefs.edit().putString(KEY_BASE_URL, formattedUrl).apply()
+ }
+
+ fun saveServerConfig(config: ServerConfigModel) {
+ prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).apply()
+ }
+
+ fun getServerConfig(): ServerConfigModel {
+ val json = prefs.getString(KEY_CONFIG_DATA, null) ?: return ServerConfigModel()
+ return try {
+ gson.fromJson(json, ServerConfigModel::class.java)
+ } catch (e: Exception) {
+ ServerConfigModel()
+ }
+ }
+
+ fun isFeatureEnabled(feature: (FeaturesConfig) -> Boolean): Boolean {
+ return feature(getServerConfig().features)
+ }
+}
diff --git a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt
new file mode 100644
index 0000000..2e8df34
--- /dev/null
+++ b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt
@@ -0,0 +1,78 @@
+package core.notifications.data
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import auth.data.remote.api.AuthApi
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ForkFirebaseMessagingService : FirebaseMessagingService() {
+
+ @Inject
+ lateinit var authApi: AuthApi
+
+ private val job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+
+ override fun onNewToken(token: String) {
+ super.onNewToken(token)
+ // Отправляем новый токен на сервер
+ scope.launch {
+ try {
+ authApi.updatePushToken(token)
+ } catch (e: Exception) {
+ // Ошибка логируется или игнорируется (сервер может быть недоступен)
+ }
+ }
+ }
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ super.onMessageReceived(message)
+
+ val title = message.notification?.title ?: message.data["title"]
+ val body = message.notification?.body ?: message.data["body"]
+ val type = message.data["type"] // "chat", "call", "story"
+
+ showNotification(title, body, type)
+ }
+
+ private fun showNotification(title: String?, body: String?, type: String?) {
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channelId = "fork_notifications_channel"
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ channelId,
+ "Fork Messenger Notifications",
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val notificationBuilder = NotificationCompat.Builder(this, channelId)
+ .setSmallIcon(android.R.drawable.ic_dialog_info) // Заменить на наш Indigo-логотип
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+
+ notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
+ }
+
+ override fun onDestroy() {
+ job.cancel()
+ super.onDestroy()
+ }
+}
diff --git a/client-mobile/core/presentation/components/AppAudioPlayer.kt b/client-mobile/core/presentation/components/AppAudioPlayer.kt
new file mode 100644
index 0000000..e47bbc7
--- /dev/null
+++ b/client-mobile/core/presentation/components/AppAudioPlayer.kt
@@ -0,0 +1,151 @@
+package core.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackParameters
+import androidx.media3.common.Player
+import androidx.media3.exoplayer.ExoPlayer
+import kotlinx.coroutines.delay
+
+@Composable
+fun AppAudioPlayer(
+ url: String,
+ isVoiceMessage: Boolean = false,
+ modifier: Modifier = Modifier,
+ contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
+) {
+ val context = LocalContext.current
+ val exoPlayer = remember {
+ ExoPlayer.Builder(context).build().apply {
+ val mediaItem = MediaItem.fromUri(url)
+ setMediaItem(mediaItem)
+ prepare()
+ }
+ }
+
+ var isPlaying by remember { mutableStateOf(false) }
+ var currentPosition by remember { mutableLongStateOf(0L) }
+ var duration by remember { mutableLongStateOf(0L) }
+ var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
+
+ DisposableEffect(Unit) {
+ val listener = object : Player.Listener {
+ override fun onIsPlayingChanged(playing: Boolean) {
+ isPlaying = playing
+ }
+ override fun onPlaybackStateChanged(state: Int) {
+ if (state == Player.STATE_READY) {
+ duration = exoPlayer.duration
+ }
+ }
+ }
+ exoPlayer.addListener(listener)
+ onDispose {
+ exoPlayer.removeListener(listener)
+ exoPlayer.release()
+ }
+ }
+
+ // Обновление прогресса
+ LaunchedEffect(isPlaying) {
+ while (isPlaying) {
+ currentPosition = exoPlayer.currentPosition
+ delay(100)
+ }
+ }
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = {
+ if (isPlaying) exoPlayer.pause() else exoPlayer.play()
+ },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = contentColor
+ )
+ }
+
+ Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
+ Slider(
+ value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
+ onValueChange = {
+ val newPos = (it * duration).toLong()
+ exoPlayer.seekTo(newPos)
+ currentPosition = newPos
+ },
+ modifier = Modifier.height(24.dp),
+ colors = SliderDefaults.colors(
+ thumbColor = contentColor,
+ activeTrackColor = contentColor,
+ inactiveTrackColor = contentColor.copy(alpha = 0.3f)
+ )
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = formatDuration(currentPosition),
+ fontSize = 10.sp,
+ color = contentColor.copy(alpha = 0.7f)
+ )
+ Text(
+ text = formatDuration(duration),
+ fontSize = 10.sp,
+ color = contentColor.copy(alpha = 0.7f)
+ )
+ }
+ }
+
+ if (isVoiceMessage) {
+ TextButton(
+ onClick = {
+ playbackSpeed = when (playbackSpeed) {
+ 1.0f -> 1.5f
+ 1.5f -> 2.0f
+ else -> 1.0f
+ }
+ exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
+ },
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier.width(40.dp)
+ ) {
+ Text(
+ text = "${playbackSpeed}x",
+ fontSize = 12.sp,
+ color = contentColor,
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+ }
+}
+
+private fun formatDuration(durationMs: Long): String {
+ val totalSeconds = durationMs / 1000
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return String.format("%02d:%02d", minutes, seconds)
+}
diff --git a/client-mobile/core/presentation/components/AppAvatar.kt b/client-mobile/core/presentation/components/AppAvatar.kt
new file mode 100644
index 0000000..72b2837
--- /dev/null
+++ b/client-mobile/core/presentation/components/AppAvatar.kt
@@ -0,0 +1,50 @@
+package core.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import core.presentation.theme.SoftSquareShape
+
+@Composable
+fun AppAvatar(
+ url: String?,
+ name: String,
+ size: Dp = 48.dp,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(SoftSquareShape) // Тот самый "мягкий квадрат"
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
+ contentAlignment = Alignment.Center
+ ) {
+ if (url != null) {
+ AsyncImage(
+ model = url,
+ contentDescription = name,
+ modifier = Modifier.size(size),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ // Заглушка, если нет аватара (первая буква имени)
+ Text(
+ text = name.take(1).uppercase(),
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = (size.value * 0.4).sp,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+}
diff --git a/client-mobile/core/presentation/components/AppVideoPlayer.kt b/client-mobile/core/presentation/components/AppVideoPlayer.kt
new file mode 100644
index 0000000..2a4a62e
--- /dev/null
+++ b/client-mobile/core/presentation/components/AppVideoPlayer.kt
@@ -0,0 +1,61 @@
+package core.presentation.components
+
+import androidx.annotation.OptIn
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import androidx.media3.ui.AspectRatioFrameLayout
+
+@OptIn(UnstableApi::class)
+@Composable
+fun AppVideoPlayer(
+ url: String,
+ modifier: Modifier = Modifier,
+ useController: Boolean = true,
+ autoPlay: Boolean = true,
+ onVideoFinished: () -> Unit = {}
+) {
+ val context = LocalContext.current
+
+ val exoPlayer = remember {
+ ExoPlayer.Builder(context).build().apply {
+ val mediaItem = MediaItem.fromUri(url)
+ setMediaItem(mediaItem)
+ prepare()
+ playWhenReady = autoPlay
+ addListener(object : Player.Listener {
+ override fun onPlaybackStateChanged(state: Int) {
+ if (state == Player.STATE_ENDED) {
+ onVideoFinished()
+ }
+ }
+ })
+ }
+ }
+
+ // Очистка ресурсов при выходе
+ DisposableEffect(Unit) {
+ onDispose {
+ exoPlayer.release()
+ }
+ }
+
+ AndroidView(
+ factory = {
+ PlayerView(it).apply {
+ player = exoPlayer
+ this.useController = useController
+ // Для сторис используем заполнение экрана
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ }
+ },
+ modifier = modifier
+ )
+}
diff --git a/client-mobile/core/presentation/components/GlassNavigationBar.kt b/client-mobile/core/presentation/components/GlassNavigationBar.kt
new file mode 100644
index 0000000..ca2b9d9
--- /dev/null
+++ b/client-mobile/core/presentation/components/GlassNavigationBar.kt
@@ -0,0 +1,155 @@
+package core.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChatBubble
+import androidx.compose.material.icons.filled.People
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import ru.knot.messager.R
+
+@Composable
+fun GlassNavigationBar(
+ currentRoute: String,
+ onNavigate: (String) -> Unit,
+ userAvatarUrl: String? = null,
+ username: String = ""
+) {
+ // Контейнер с эффектом стекла
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ .height(72.dp)
+ .clip(RoundedCornerShape(32.dp))
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
+ )
+ )
+ )
+ .border(
+ 1.dp,
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.White.copy(alpha = 0.2f),
+ Color.Transparent
+ )
+ ),
+ RoundedCornerShape(32.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ NavItem(
+ icon = Icons.Default.ChatBubble,
+ label = stringResource(R.string.chats),
+ isSelected = currentRoute == "chat_list",
+ onClick = { onNavigate("chat_list") }
+ )
+ NavItem(
+ icon = Icons.Default.People,
+ label = stringResource(R.string.contacts_tab),
+ isSelected = currentRoute == "contacts",
+ onClick = { onNavigate("contacts") }
+ )
+ NavItem(
+ icon = Icons.Default.Settings,
+ label = stringResource(R.string.settings),
+ isSelected = currentRoute == "settings",
+ onClick = { onNavigate("settings") }
+ )
+ // Профиль с аватаром
+ ProfileNavItem(
+ avatarUrl = userAvatarUrl,
+ username = username,
+ isSelected = currentRoute.startsWith("profile"),
+ onClick = { onNavigate("profile") }
+ )
+ }
+ }
+}
+
+@Composable
+private fun NavItem(
+ icon: ImageVector,
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ val contentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent
+
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(backgroundColor)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(icon, contentDescription = label, tint = contentColor, modifier = Modifier.size(24.dp))
+ Text(text = label, color = contentColor, fontSize = 10.sp, modifier = Modifier.padding(top = 2.dp))
+ }
+}
+
+@Composable
+private fun ProfileNavItem(
+ avatarUrl: String?,
+ username: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
+
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .border(2.dp, borderColor, CircleShape)
+ .padding(2.dp)
+ ) {
+ AppAvatar(
+ url = avatarUrl,
+ name = username,
+ size = 24.dp
+ )
+ }
+ Text(
+ text = stringResource(R.string.profile_tab),
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 10.sp,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ }
+}
diff --git a/client-mobile/core/presentation/settings/SettingsScreen.kt b/client-mobile/core/presentation/settings/SettingsScreen.kt
new file mode 100644
index 0000000..3216593
--- /dev/null
+++ b/client-mobile/core/presentation/settings/SettingsScreen.kt
@@ -0,0 +1,102 @@
+package core.presentation.settings
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ onLogout: () -> Unit = {},
+ viewModel: SettingsViewModel = hiltViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+ var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.settings)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ actions = {
+ IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
+ Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.server_connection),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ OutlinedTextField(
+ value = baseUrl,
+ onValueChange = { baseUrl = it },
+ label = { Text(stringResource(R.string.api_base_url)) },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("https://example.com/api/") }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.server_features),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ FeatureStatusItem(stringResource(R.string.stories), state.config.features.stories)
+ FeatureStatusItem(stringResource(R.string.polls), state.config.features.polls)
+ FeatureStatusItem(stringResource(R.string.calls), state.config.features.calls)
+ FeatureStatusItem(stringResource(R.string.groups), state.config.features.groups)
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.limits),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text("${stringResource(R.string.max_file_size)}: ${state.config.limits.maxFileSize / (1024 * 1024)} MB")
+ Text("${stringResource(R.string.max_group_members)}: ${state.config.limits.maxGroupMembers}")
+ }
+ }
+}
+
+@Composable
+fun FeatureStatusItem(name: String, enabled: Boolean) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(name)
+ Text(
+ text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
+ color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
+ )
+ }
+}
diff --git a/client-mobile/core/presentation/settings/SettingsViewModel.kt b/client-mobile/core/presentation/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..7a4a526
--- /dev/null
+++ b/client-mobile/core/presentation/settings/SettingsViewModel.kt
@@ -0,0 +1,41 @@
+package core.presentation.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import core.domain.model.ServerConfigModel
+import core.network.ServerConfig
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class SettingsState(
+ val baseUrl: String = "",
+ val config: ServerConfigModel = ServerConfigModel()
+)
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val serverConfig: ServerConfig
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(SettingsState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ _state.update {
+ it.copy(
+ baseUrl = serverConfig.getBaseUrl(),
+ config = serverConfig.getServerConfig()
+ )
+ }
+ }
+
+ fun saveBaseUrl(url: String) {
+ serverConfig.setBaseUrl(url)
+ _state.update { it.copy(baseUrl = url) }
+ }
+}
diff --git a/client-mobile/core/presentation/theme/Theme.kt b/client-mobile/core/presentation/theme/Theme.kt
new file mode 100644
index 0000000..d6664e7
--- /dev/null
+++ b/client-mobile/core/presentation/theme/Theme.kt
@@ -0,0 +1,41 @@
+package core.presentation.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+// Цвета из веб-версии (Dark Mode)
+val DarkBackground = Color(0xFF0F0F10)
+val SurfaceContainer = Color(0xFF161618)
+val Primary = Color(0xFF6366F1) // Indigo/Primary из Tailwind
+val PrimaryIndigo = Primary
+val Indigo500 = Primary
+val OnSurfaceVariant = Color(0xFF94A3B8)
+
+private val DarkColors = darkColorScheme(
+ primary = Primary,
+ background = DarkBackground,
+ surface = SurfaceContainer,
+ onPrimary = Color.White,
+ onBackground = Color.White,
+ onSurface = Color.White,
+ onSurfaceVariant = OnSurfaceVariant
+)
+
+val SoftSquareShape = RoundedCornerShape(24.dp) // "Мягкий квадрат"
+
+@Composable
+fun ForkMessengerTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = DarkColors,
+ shapes = MaterialTheme.shapes.copy(
+ small = RoundedCornerShape(12.dp),
+ medium = SoftSquareShape,
+ large = RoundedCornerShape(32.dp)
+ ),
+ content = content
+ )
+}
diff --git a/client-mobile/core/security/TokenManager.kt b/client-mobile/core/security/TokenManager.kt
new file mode 100644
index 0000000..262fc76
--- /dev/null
+++ b/client-mobile/core/security/TokenManager.kt
@@ -0,0 +1,34 @@
+package core.security
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TokenManager @Inject constructor(context: Context) {
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val prefs = EncryptedSharedPreferences.create(
+ context,
+ "auth_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ fun saveToken(token: String) {
+ prefs.edit().putString("jwt_token", token).apply()
+ }
+
+ fun getToken(): String? {
+ return prefs.getString("jwt_token", null)
+ }
+
+ fun deleteToken() {
+ prefs.edit().remove("jwt_token").apply()
+ }
+}
diff --git a/client-mobile/core/utils/FileUtils.kt b/client-mobile/core/utils/FileUtils.kt
new file mode 100644
index 0000000..12f5e9d
--- /dev/null
+++ b/client-mobile/core/utils/FileUtils.kt
@@ -0,0 +1,23 @@
+package core.utils
+
+import android.content.Context
+import android.net.Uri
+import java.io.File
+import java.io.FileOutputStream
+import java.util.UUID
+
+fun copyUriToFile(context: Context, uri: Uri): File? {
+ return try {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val file = File(context.cacheDir, "${UUID.randomUUID()}.tmp")
+ val outputStream = FileOutputStream(file)
+ inputStream?.use { input ->
+ outputStream.use { output ->
+ input.copyTo(output)
+ }
+ }
+ file
+ } catch (e: Exception) {
+ null
+ }
+}
diff --git a/client-mobile/core/utils/ImageCropper.kt b/client-mobile/core/utils/ImageCropper.kt
new file mode 100644
index 0000000..ecf123b
--- /dev/null
+++ b/client-mobile/core/utils/ImageCropper.kt
@@ -0,0 +1,32 @@
+package core.utils
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.ui.graphics.toArgb
+import com.yalantis.ucrop.UCrop
+import core.presentation.theme.Indigo500
+import java.io.File
+
+object ImageCropper {
+ fun getCropIntent(
+ context: Context,
+ sourceUri: Uri,
+ destinationUri: Uri
+ ): UCrop {
+ val options = UCrop.Options().apply {
+ setCompressionQuality(90)
+ setHideBottomControls(false)
+ setFreeStyleCropEnabled(false) // Фиксированный аспект
+ setToolbarColor(Indigo500.toArgb())
+ setStatusBarColor(Indigo500.toArgb())
+ setActiveControlsWidgetColor(Indigo500.toArgb())
+ setToolbarTitle("Crop Avatar")
+ // Настройка под "квадрат" (софт-сквайр эмулируется на стороне UI, кроп всегда 1:1)
+ }
+
+ return UCrop.of(sourceUri, destinationUri)
+ .withAspectRatio(1f, 1f)
+ .withMaxResultSize(1000, 1000)
+ .withOptions(options)
+ }
+}
diff --git a/client-mobile/core/utils/LinkParser.kt b/client-mobile/core/utils/LinkParser.kt
new file mode 100644
index 0000000..2fc0b4f
--- /dev/null
+++ b/client-mobile/core/utils/LinkParser.kt
@@ -0,0 +1,19 @@
+package core.utils
+
+data class LinkMetadata(
+ val url: String,
+ val title: String? = null,
+ val description: String? = null,
+ val imageUrl: String? = null
+)
+
+object LinkParser {
+ private val URL_PATTERN = Regex(
+ "(?:^|[\\s])((https?://)[\\w-]+(\\.[\\w-]+)+\\.?(:\\d+)?(/[\\w- ./?%&=]*)?)",
+ RegexOption.IGNORE_CASE
+ )
+
+ fun findLinks(text: String): List {
+ return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList()
+ }
+}
diff --git a/client-mobile/core/utils/VoiceRecorder.kt b/client-mobile/core/utils/VoiceRecorder.kt
new file mode 100644
index 0000000..4707ef4
--- /dev/null
+++ b/client-mobile/core/utils/VoiceRecorder.kt
@@ -0,0 +1,34 @@
+package core.utils
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.os.Build
+import java.io.File
+import java.io.FileOutputStream
+
+class VoiceRecorder(private val context: Context) {
+ private var recorder: MediaRecorder? = null
+
+ fun startRecording(outputFile: File) {
+ recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ MediaRecorder()
+ }.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setOutputFile(FileOutputStream(outputFile).fd)
+ prepare()
+ start()
+ }
+ }
+
+ fun stopRecording() {
+ recorder?.apply {
+ stop()
+ release()
+ }
+ recorder = null
+ }
+}
diff --git a/client-mobile/gradle.properties b/client-mobile/gradle.properties
new file mode 100644
index 0000000..197bc4d
--- /dev/null
+++ b/client-mobile/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+android.enableJetifier=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
+kotlin.code.style=official
diff --git a/client-mobile/gradle/wrapper/gradle-wrapper.properties b/client-mobile/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..1af9e09
--- /dev/null
+++ b/client-mobile/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/client-mobile/local.properties b/client-mobile/local.properties
new file mode 100644
index 0000000..4b457b4
--- /dev/null
+++ b/client-mobile/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Tue Apr 14 00:13:58 MSK 2026
+sdk.dir=C\:\\Users\\HomePC\\AppData\\Local\\Android\\Sdk
diff --git a/client-mobile/navigation/AppNavigation.kt b/client-mobile/navigation/AppNavigation.kt
new file mode 100644
index 0000000..1f5bb8f
--- /dev/null
+++ b/client-mobile/navigation/AppNavigation.kt
@@ -0,0 +1,206 @@
+package navigation
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import auth.presentation.LoginScreen
+import auth.presentation.RegisterScreen
+import auth.presentation.AuthViewModel
+import core.presentation.settings.SettingsScreen
+import core.presentation.settings.SettingsViewModel
+import chats.presentation.chat_list.ChatListScreen
+import chats.presentation.chat_list.ChatListViewModel
+import chats.presentation.chat_detail.ChatDetailScreen
+import chats.presentation.chat_detail.ChatDetailViewModel
+import contacts.presentation.ContactListScreen
+import contacts.presentation.ContactListViewModel
+import core.presentation.components.GlassNavigationBar
+import profiles.presentation.ProfileScreen
+import profiles.presentation.EditProfileScreen
+import profiles.presentation.viewmodel.ProfileViewModel
+import stories.presentation.StoryViewModel
+
+sealed class Screen(val route: String) {
+ object Login : Screen("login")
+ object Register : Screen("register")
+ object ChatList : Screen("chat_list")
+ object ChatDetail : Screen("chat_detail/{chatId}/{chatName}") {
+ fun createRoute(chatId: String, chatName: String) = "chat_detail/$chatId/$chatName"
+ }
+ object Profile : Screen("profile?profileId={profileId}") {
+ fun createRoute(profileId: String? = null) = if (profileId != null) "profile?profileId=$profileId" else "profile"
+ }
+ object EditProfile : Screen("edit_profile")
+ object Settings : Screen("settings")
+ object Contacts : Screen("contacts")
+}
+
+@Composable
+fun AppNavigation(
+ navController: NavHostController = rememberNavController(),
+ startDestination: String = Screen.Login.route
+) {
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = navBackStackEntry?.destination?.route
+
+ // Определяем, нужно ли показывать нижнюю панель
+ val showBottomBar = currentRoute in listOf(
+ Screen.ChatList.route,
+ Screen.Contacts.route,
+ Screen.Settings.route,
+ Screen.Profile.route
+ )
+
+ Scaffold(
+ bottomBar = {
+ if (showBottomBar) {
+ GlassNavigationBar(
+ currentRoute = currentRoute ?: "",
+ onNavigate = { route ->
+ navController.navigate(route) {
+ popUpTo(Screen.ChatList.route) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ userAvatarUrl = null,
+ username = "Me"
+ )
+ }
+ }
+ ) { paddingValues ->
+ Box(modifier = Modifier.padding(if (showBottomBar) paddingValues else PaddingValues(0.dp))) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination
+ ) {
+ composable(Screen.Login.route) {
+ val viewModel: AuthViewModel = hiltViewModel()
+ LoginScreen(
+ viewModel = viewModel,
+ onNavigateToRegister = { navController.navigate(Screen.Register.route) },
+ onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
+ onLoginSuccess = {
+ navController.navigate(Screen.ChatList.route) {
+ popUpTo(Screen.Login.route) { inclusive = true }
+ }
+ }
+ )
+ }
+ composable(Screen.Register.route) {
+ val viewModel: AuthViewModel = hiltViewModel()
+ RegisterScreen(
+ viewModel = viewModel,
+ onNavigateToLogin = { navController.popBackStack() },
+ onRegisterSuccess = {
+ navController.navigate(Screen.ChatList.route) {
+ popUpTo(Screen.Login.route) { inclusive = true }
+ }
+ }
+ )
+ }
+ composable(Screen.ChatList.route) {
+ val viewModel: ChatListViewModel = hiltViewModel()
+ val storyViewModel: StoryViewModel = hiltViewModel()
+ ChatListScreen(
+ viewModel = viewModel,
+ storyViewModel = storyViewModel,
+ onChatClick = { chatId ->
+ navController.navigate(Screen.ChatDetail.createRoute(chatId, "Chat"))
+ },
+ onStoryClick = { /* Story click logic */ }
+ )
+ }
+ composable(
+ route = Screen.ChatDetail.route,
+ arguments = listOf(
+ navArgument("chatId") { type = NavType.StringType },
+ navArgument("chatName") { type = NavType.StringType }
+ )
+ ) { backStackEntry ->
+ val chatId = backStackEntry.arguments?.getString("chatId") ?: ""
+ val chatName = backStackEntry.arguments?.getString("chatName") ?: "Chat"
+ val viewModel: ChatDetailViewModel = hiltViewModel()
+
+ ChatDetailScreen(
+ chatName = chatName,
+ viewModel = viewModel,
+ onBack = { navController.popBackStack() }
+ )
+ }
+ composable(Screen.Contacts.route) {
+ val viewModel: ContactListViewModel = hiltViewModel()
+ val state by viewModel.state.collectAsState()
+ ContactListScreen(
+ contacts = state.contacts,
+ isLoading = state.isLoading,
+ onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) },
+ onSearchChange = { viewModel.onSearchChange(it) }
+ )
+ }
+ composable(
+ route = Screen.Profile.route,
+ arguments = listOf(
+ navArgument("profileId") {
+ type = NavType.StringType
+ nullable = true
+ }
+ )
+ ) { backStackEntry ->
+ val profileId = backStackEntry.arguments?.getString("profileId")
+ val viewModel: ProfileViewModel = hiltViewModel()
+
+ ProfileScreen(
+ profileId = profileId,
+ viewModel = viewModel,
+ onEditProfile = { navController.navigate(Screen.EditProfile.route) },
+ onBack = { navController.popBackStack() },
+ onSendMessage = { id -> navController.navigate(Screen.ChatDetail.createRoute(id, "Chat")) }
+ )
+ }
+ composable(Screen.EditProfile.route) {
+ val viewModel: ProfileViewModel = hiltViewModel()
+ val state by viewModel.state.collectAsState()
+
+ EditProfileScreen(
+ currentDisplayName = state.profile?.displayName,
+ currentUsername = state.profile?.username ?: "",
+ currentBio = state.profile?.bio,
+ currentAvatarUrl = state.profile?.avatarUrl,
+ onSave = { name, bio, uri ->
+ viewModel.updateProfile(name, bio, uri)
+ navController.popBackStack()
+ },
+ onBack = { navController.popBackStack() }
+ )
+ }
+ composable(Screen.Settings.route) {
+ val viewModel: SettingsViewModel = hiltViewModel()
+ SettingsScreen(
+ viewModel = viewModel,
+ onBack = { navController.popBackStack() },
+ onLogout = {
+ navController.navigate(Screen.Login.route) {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/client-mobile/profiles/data/remote/api/ProfileApi.kt b/client-mobile/profiles/data/remote/api/ProfileApi.kt
new file mode 100644
index 0000000..84c36cb
--- /dev/null
+++ b/client-mobile/profiles/data/remote/api/ProfileApi.kt
@@ -0,0 +1,27 @@
+package profiles.data.remote.api
+
+import okhttp3.MultipartBody
+import profiles.data.remote.dto.ProfileDto
+import profiles.data.remote.dto.UpdateProfileRequest
+import retrofit2.http.*
+
+interface ProfileApi {
+ @GET("profiles/{id}")
+ suspend fun getProfile(@Path("id") id: String): ProfileDto
+
+ @GET("profiles/me")
+ suspend fun getMyProfile(): ProfileDto
+
+ @PUT("profiles/profile")
+ suspend fun updateProfile(@Body request: UpdateProfileRequest): ProfileDto
+
+ @Multipart
+ @POST("profiles/avatar")
+ suspend fun uploadAvatar(@Part avatar: MultipartBody.Part): ProfileDto
+
+ @DELETE("profiles/avatar")
+ suspend fun removeAvatar(): ProfileDto
+
+ @GET("profiles/search")
+ suspend fun searchProfiles(@Query("q") query: String): List
+}
diff --git a/client-mobile/profiles/data/remote/dto/ProfileDto.kt b/client-mobile/profiles/data/remote/dto/ProfileDto.kt
new file mode 100644
index 0000000..984f72f
--- /dev/null
+++ b/client-mobile/profiles/data/remote/dto/ProfileDto.kt
@@ -0,0 +1,16 @@
+package profiles.data.remote.dto
+
+data class ProfileDto(
+ val id: String,
+ val username: String,
+ val displayName: String?,
+ val avatarUrl: String?,
+ val bio: String?,
+ val isOnline: Boolean = false,
+ val lastSeen: String? = null
+)
+
+data class UpdateProfileRequest(
+ val displayName: String?,
+ val bio: String?
+)
diff --git a/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt b/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt
new file mode 100644
index 0000000..816f5e4
--- /dev/null
+++ b/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt
@@ -0,0 +1,55 @@
+package profiles.data.repository
+
+import android.content.Context
+import android.net.Uri
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import profiles.data.remote.api.ProfileApi
+import profiles.data.remote.dto.ProfileDto
+import profiles.data.remote.dto.UpdateProfileRequest
+import profiles.domain.repository.ProfileRepository
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+
+class ProfileRepositoryImpl @Inject constructor(
+ private val api: ProfileApi,
+ private val context: Context
+) : ProfileRepository {
+
+ override suspend fun getProfile(id: String): Result = runCatching {
+ api.getProfile(id)
+ }
+
+ override suspend fun getMyProfile(): Result = runCatching {
+ api.getMyProfile()
+ }
+
+ override suspend fun updateProfile(displayName: String?, bio: String?): Result = runCatching {
+ api.updateProfile(UpdateProfileRequest(displayName, bio))
+ }
+
+ override suspend fun uploadAvatar(uri: Uri): Result = runCatching {
+ val file = uriToFile(uri)
+ val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
+ val body = MultipartBody.Part.createFormData("avatar", file.name, requestFile)
+ api.uploadAvatar(body)
+ }
+
+ override suspend fun removeAvatar(): Result = runCatching {
+ api.removeAvatar()
+ }
+
+ private fun uriToFile(uri: Uri): File {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val file = File(context.cacheDir, "temp_avatar_${System.currentTimeMillis()}.jpg")
+ val outputStream = FileOutputStream(file)
+ inputStream?.use { input ->
+ outputStream.use { output ->
+ input.copyTo(output)
+ }
+ }
+ return file
+ }
+}
diff --git a/client-mobile/profiles/di/ProfileModule.kt b/client-mobile/profiles/di/ProfileModule.kt
new file mode 100644
index 0000000..9b4f8e8
--- /dev/null
+++ b/client-mobile/profiles/di/ProfileModule.kt
@@ -0,0 +1,29 @@
+package profiles.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import profiles.data.remote.api.ProfileApi
+import profiles.data.repository.ProfileRepositoryImpl
+import profiles.domain.repository.ProfileRepository
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ProfileModule {
+
+ @Provides
+ @Singleton
+ fun provideProfileApi(retrofit: Retrofit): ProfileApi {
+ return retrofit.create(ProfileApi::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideProfileRepository(api: ProfileApi, context: Context): ProfileRepository {
+ return ProfileRepositoryImpl(api, context)
+ }
+}
diff --git a/client-mobile/profiles/domain/repository/ProfileRepository.kt b/client-mobile/profiles/domain/repository/ProfileRepository.kt
new file mode 100644
index 0000000..8e22895
--- /dev/null
+++ b/client-mobile/profiles/domain/repository/ProfileRepository.kt
@@ -0,0 +1,12 @@
+package profiles.domain.repository
+
+import android.net.Uri
+import profiles.data.remote.dto.ProfileDto
+
+interface ProfileRepository {
+ suspend fun getProfile(id: String): Result
+ suspend fun getMyProfile(): Result
+ suspend fun updateProfile(displayName: String?, bio: String?): Result
+ suspend fun uploadAvatar(uri: Uri): Result
+ suspend fun removeAvatar(): Result
+}
diff --git a/client-mobile/profiles/presentation/EditProfileScreen.kt b/client-mobile/profiles/presentation/EditProfileScreen.kt
new file mode 100644
index 0000000..1af713e
--- /dev/null
+++ b/client-mobile/profiles/presentation/EditProfileScreen.kt
@@ -0,0 +1,208 @@
+package profiles.presentation
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.platform.LocalContext
+import coil.compose.AsyncImage
+import core.presentation.components.AppAvatar
+import core.presentation.theme.SoftSquareShape
+import ru.knot.messager.R
+
+import android.app.Activity
+import android.content.Intent
+import com.yalantis.ucrop.UCrop
+import core.utils.ImageCropper
+import java.io.File
+import java.util.UUID
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditProfileScreen(
+ currentDisplayName: String?,
+ currentUsername: String,
+ currentBio: String?,
+ currentAvatarUrl: String?,
+ onSave: (displayName: String, bio: String, avatarUri: Uri?) -> Unit,
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ var displayName by remember { mutableStateOf(currentDisplayName ?: "") }
+ var bio by remember { mutableStateOf(currentBio ?: "") }
+ var selectedImageUri by remember { mutableStateOf(null) }
+
+ // Лаунчер для результата кропа
+ val cropLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult(),
+ onResult = { result ->
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
+ selectedImageUri = UCrop.getOutput(result.data!!)
+ }
+ }
+ )
+
+ // Лаунчер для выбора фото из галереи
+ val photoPickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.PickVisualMedia(),
+ onResult = { uri ->
+ if (uri != null) {
+ // После выбора фото запускаем UCrop
+ val destinationUri = Uri.fromFile(File(context.cacheDir, "${UUID.randomUUID()}.jpg"))
+ val cropIntent = ImageCropper.getCropIntent(context, uri, destinationUri).getIntent(context)
+ cropLauncher.launch(cropIntent)
+ }
+ }
+ )
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.edit_profile)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ actions = {
+ IconButton(onClick = { onSave(displayName, bio, selectedImageUri) }) {
+ Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save), tint = MaterialTheme.colorScheme.primary)
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Смена фото
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .clip(SoftSquareShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable {
+ photoPickerLauncher.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ if (selectedImageUri != null) {
+ AsyncImage(
+ model = selectedImageUri,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ AppAvatar(
+ url = currentAvatarUrl,
+ name = currentUsername,
+ size = 120.dp
+ )
+ }
+
+ // Overlay
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.3f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.CameraAlt,
+ contentDescription = stringResource(R.string.change_photo),
+ tint = Color.White
+ )
+ }
+ }
+
+ TextButton(
+ onClick = {
+ photoPickerLauncher.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+ )
+ },
+ modifier = Modifier.padding(top = 8.dp)
+ ) {
+ Text(stringResource(R.string.change_photo))
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Поля ввода
+ OutlinedTextField(
+ value = displayName,
+ onValueChange = { displayName = it },
+ label = { Text(stringResource(R.string.display_name)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ shape = SoftSquareShape
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = "@$currentUsername",
+ onValueChange = {},
+ label = { Text(stringResource(R.string.username_label)) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = false, // Обычно username меняется отдельно или вообще не меняется
+ shape = SoftSquareShape,
+ colors = OutlinedTextFieldDefaults.colors(
+ disabledBorderColor = MaterialTheme.colorScheme.outlineVariant,
+ disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = bio,
+ onValueChange = { bio = it },
+ label = { Text(stringResource(R.string.bio)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 100.dp),
+ shape = SoftSquareShape,
+ maxLines = 5
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = { onSave(displayName, bio, selectedImageUri) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = SoftSquareShape
+ ) {
+ Text(stringResource(R.string.save))
+ }
+ }
+ }
+}
diff --git a/client-mobile/profiles/presentation/ProfileScreen.kt b/client-mobile/profiles/presentation/ProfileScreen.kt
new file mode 100644
index 0000000..2b9dce8
--- /dev/null
+++ b/client-mobile/profiles/presentation/ProfileScreen.kt
@@ -0,0 +1,304 @@
+package profiles.presentation
+
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.*
+import androidx.compose.foundation.lazy.grid.*
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil.compose.AsyncImage
+import core.presentation.components.AppAvatar
+import core.presentation.theme.SoftSquareShape
+import kotlinx.coroutines.launch
+import profiles.presentation.viewmodel.ProfileViewModel
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun ProfileScreen(
+ profileId: String? = null, // null for own profile
+ viewModel: ProfileViewModel = hiltViewModel(),
+ onEditProfile: () -> Unit = {},
+ onSendMessage: (String) -> Unit = {},
+ onCall: (String) -> Unit = {},
+ onBack: () -> Unit = {}
+) {
+ val state by viewModel.state.collectAsState()
+
+ LaunchedEffect(profileId) {
+ viewModel.loadProfile(profileId)
+ }
+
+ val profile = state.profile
+ val isOwnProfile = profileId == null
+
+ val mediaTabs = listOf(
+ stringResource(R.string.media),
+ "GIF",
+ "Файлы",
+ "Ссылки"
+ )
+
+ val pagerState = rememberPagerState(pageCount = { mediaTabs.size })
+ val coroutineScope = rememberCoroutineScope()
+
+ if (state.isLoading) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ return
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (isOwnProfile) stringResource(R.string.profile) else stringResource(R.string.profile)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ actions = {
+ if (isOwnProfile) {
+ IconButton(onClick = onEditProfile) {
+ Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit_profile))
+ }
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ item {
+ ProfileHeader(
+ username = profile?.username ?: "",
+ displayName = profile?.displayName,
+ avatarUrl = profile?.avatarUrl,
+ bio = profile?.bio,
+ isOwnProfile = isOwnProfile,
+ isCallsEnabled = true,
+ onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
+ onCall = { profile?.id?.let { id -> onCall(id) } }
+ )
+ }
+
+ stickyHeader {
+ Surface(modifier = Modifier.fillMaxWidth()) {
+ ScrollableTabRow(
+ selectedTabIndex = pagerState.currentPage,
+ edgePadding = 16.dp,
+ containerColor = MaterialTheme.colorScheme.surface,
+ divider = {}
+ ) {
+ mediaTabs.forEachIndexed { index, title ->
+ Tab(
+ selected = pagerState.currentPage == index,
+ onClick = {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(index)
+ }
+ },
+ text = { Text(title) }
+ )
+ }
+ }
+ }
+ }
+
+ // Using item with fillParentMaxSize for Pager inside LazyColumn
+ // is tricky, but here we render content directly in LazyColumn
+ // to maintain scrolling, or use a fixed height.
+ // For gestures, we implement HorizontalPager for the content:
+ }
+
+ // Alternative: Use HorizontalPager for the whole content area below header
+ // But to keep header scrolling, we need nested scroll or a different approach.
+ // Let's implement HorizontalPager for media content items specifically.
+ }
+}
+
+@Composable
+fun ProfileHeader(
+ username: String,
+ displayName: String?,
+ avatarUrl: String?,
+ bio: String?,
+ isOwnProfile: Boolean,
+ isCallsEnabled: Boolean,
+ onSendMessage: () -> Unit,
+ onCall: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(contentAlignment = Alignment.BottomEnd) {
+ AppAvatar(
+ url = avatarUrl,
+ name = username,
+ size = 120.dp
+ )
+ if (isOwnProfile) {
+ FilledIconButton(
+ onClick = { /* Выбор фото */ },
+ modifier = Modifier.size(32.dp).offset(x = 4.dp, y = 4.dp),
+ shape = CircleShape,
+ colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.primary)
+ ) {
+ Icon(Icons.Default.CameraAlt, contentDescription = null, modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = displayName ?: username, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Text(text = "@$username", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+
+ if (!isOwnProfile) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ ActionCircleButton(Icons.Default.Message, stringResource(R.string.message), onSendMessage)
+ if (isCallsEnabled) {
+ ActionCircleButton(Icons.Default.Call, stringResource(R.string.call), onCall)
+ }
+ ActionCircleButton(Icons.Default.Notifications, stringResource(R.string.notifications), {})
+ }
+ }
+
+ if (bio != null) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(text = stringResource(R.string.bio), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
+ Text(text = bio, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+}
+
+fun LazyListScope.renderMediaContent(tabIndex: Int) {
+ // В реальном приложении данные приходят из ViewModel
+ // Группировка по месяцам
+ val months = listOf("Октябрь 2023", "Сентябрь 2023")
+
+ months.forEach { month ->
+ item {
+ Text(
+ text = month,
+ modifier = Modifier.padding(16.dp, 8.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ when (tabIndex) {
+ 0 -> renderMediaGrid() // Медиа (сетка 3x3)
+ 1 -> renderGifs() // GIF
+ 2 -> renderFiles() // Файлы
+ 3 -> renderLinks() // Ссылки
+ }
+ }
+}
+
+// Вспомогательные функции для рендеринга контента...
+fun LazyListScope.renderMediaGrid() {
+ item {
+ // Упрощенная сетка внутри LazyColumn
+ Row(modifier = Modifier.padding(horizontal = 16.dp)) {
+ repeat(3) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(1f)
+ .padding(2.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.Gray.copy(alpha = 0.2f))
+ )
+ }
+ }
+ }
+}
+
+fun LazyListScope.renderFiles() {
+ items(3) {
+ ListItem(
+ headlineContent = { Text("Document.pdf") },
+ supportingContent = { Text("2.4 MB • 12.10.23") },
+ leadingContent = { Icon(Icons.Default.Description, contentDescription = null) }
+ )
+ }
+}
+
+fun LazyListScope.renderLinks() {
+ items(2) {
+ ListItem(
+ headlineContent = { Text("https://github.com/forkmessager") },
+ supportingContent = { Text("GitHub - ForkMessager Project") },
+ leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
+ )
+ }
+}
+
+fun LazyListScope.renderGifs() {
+ item {
+ Row(modifier = Modifier.padding(horizontal = 16.dp)) {
+ repeat(2) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(120.dp)
+ .padding(2.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.Gray.copy(alpha = 0.1f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("GIF", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ActionCircleButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ onClick: () -> Unit,
+ color: Color = MaterialTheme.colorScheme.primary
+) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ FilledIconButton(
+ onClick = onClick,
+ modifier = Modifier.size(56.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(containerColor = color.copy(alpha = 0.1f))
+ ) {
+ Icon(icon, contentDescription = label, tint = color)
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(text = label, style = MaterialTheme.typography.labelSmall)
+ }
+}
diff --git a/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt b/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt
new file mode 100644
index 0000000..716bc6f
--- /dev/null
+++ b/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt
@@ -0,0 +1,74 @@
+package profiles.presentation.viewmodel
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import profiles.data.remote.dto.ProfileDto
+import profiles.domain.repository.ProfileRepository
+import javax.inject.Inject
+
+data class ProfileState(
+ val profile: ProfileDto? = null,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val isSaving: Boolean = false
+)
+
+@HiltViewModel
+class ProfileViewModel @Inject constructor(
+ private val repository: ProfileRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(ProfileState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun loadProfile(id: String? = null) {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isLoading = true)
+ val result = if (id == null) {
+ repository.getMyProfile()
+ } else {
+ repository.getProfile(id)
+ }
+
+ result.onSuccess { profile ->
+ _state.value = _state.value.copy(profile = profile, isLoading = false)
+ }.onFailure { error ->
+ _state.value = _state.value.copy(error = error.message, isLoading = false)
+ }
+ }
+ }
+
+ fun updateProfile(displayName: String?, bio: String?, avatarUri: Uri? = null) {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isSaving = true)
+
+ // 1. Обновляем аватар, если он выбран
+ avatarUri?.let {
+ repository.uploadAvatar(it)
+ }
+
+ // 2. Обновляем текстовые данные
+ repository.updateProfile(displayName, bio)
+ .onSuccess { updatedProfile ->
+ _state.value = _state.value.copy(profile = updatedProfile, isSaving = false)
+ }
+ .onFailure { error ->
+ _state.value = _state.value.copy(error = error.message, isSaving = false)
+ }
+ }
+ }
+
+ fun removeAvatar() {
+ viewModelScope.launch {
+ repository.removeAvatar().onSuccess { updatedProfile ->
+ _state.value = _state.value.copy(profile = updatedProfile)
+ }
+ }
+ }
+}
diff --git a/client-mobile/settings.gradle.kts b/client-mobile/settings.gradle.kts
new file mode 100644
index 0000000..f629523
--- /dev/null
+++ b/client-mobile/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "KnotMessenger"
+include(":app")
diff --git a/client-mobile/settings/presentation/SettingsScreen.kt b/client-mobile/settings/presentation/SettingsScreen.kt
new file mode 100644
index 0000000..2fec055
--- /dev/null
+++ b/client-mobile/settings/presentation/SettingsScreen.kt
@@ -0,0 +1,185 @@
+package settings.presentation
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit = {},
+ onLogout: () -> Unit = {}
+) {
+ var notificationsEnabled by remember { mutableStateOf(true) }
+ var previewEnabled by remember { mutableStateOf(true) }
+ var privacyOnlineStatus by remember { mutableStateOf(true) }
+ var darkThemeEnabled by remember { mutableStateOf(true) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.settings)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = null)
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp)
+ ) {
+ item { SectionHeader(stringResource(R.string.notifications)) }
+ item {
+ SettingsToggleItem(
+ icon = Icons.Default.Notifications,
+ title = "Включить уведомления",
+ subtitle = "Звуки и вибрация",
+ checked = notificationsEnabled,
+ onCheckedChange = { notificationsEnabled = it }
+ )
+ }
+ item {
+ SettingsToggleItem(
+ icon = Icons.Default.Visibility,
+ title = "Предпросмотр сообщений",
+ subtitle = "Показывать текст в уведомлениях",
+ checked = previewEnabled,
+ onCheckedChange = { previewEnabled = it }
+ )
+ }
+
+ item { Spacer(modifier = Modifier.height(16.dp)) }
+ item { SectionHeader("Конфиденциальность") }
+ item {
+ SettingsToggleItem(
+ icon = Icons.Default.Lock,
+ title = "Статус «В сети»",
+ subtitle = "Кто видит время вашего посещения",
+ checked = privacyOnlineStatus,
+ onCheckedChange = { privacyOnlineStatus = it }
+ )
+ }
+ item {
+ SettingsClickItem(
+ icon = Icons.Default.Block,
+ title = "Черный список",
+ subtitle = "0 пользователей",
+ onClick = { /* TODO */ }
+ )
+ }
+
+ item { Spacer(modifier = Modifier.height(16.dp)) }
+ item { SectionHeader("Внешний вид") }
+ item {
+ SettingsToggleItem(
+ icon = Icons.Default.DarkMode,
+ title = "Темная тема",
+ subtitle = "Indigo стиль",
+ checked = darkThemeEnabled,
+ onCheckedChange = { darkThemeEnabled = it }
+ )
+ }
+ item {
+ SettingsClickItem(
+ icon = Icons.Default.Language,
+ title = "Язык приложения",
+ subtitle = "Русский",
+ onClick = { /* TODO: Language Picker */ }
+ )
+ }
+
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+ item {
+ Button(
+ onClick = onLogout,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.error),
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Icon(Icons.Default.Logout, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Выйти из аккаунта")
+ }
+ }
+ item { Spacer(modifier = Modifier.height(100.dp)) } // Padding for BottomBar
+ }
+ }
+}
+
+@Composable
+fun SectionHeader(title: String) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp)
+ )
+}
+
+@Composable
+fun SettingsToggleItem(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clip(RoundedCornerShape(24.dp)),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+ }
+}
+
+@Composable
+fun SettingsClickItem(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ onClick: () -> Unit
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clip(RoundedCornerShape(24.dp)),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ onClick = onClick
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
diff --git a/client-mobile/stories/data/remote/api/StoryApi.kt b/client-mobile/stories/data/remote/api/StoryApi.kt
new file mode 100644
index 0000000..3276457
--- /dev/null
+++ b/client-mobile/stories/data/remote/api/StoryApi.kt
@@ -0,0 +1,55 @@
+package stories.data.remote.api
+
+import retrofit2.http.*
+import okhttp3.MultipartBody
+
+interface StoryApi {
+ @GET("stories")
+ suspend fun getStories(): List
+
+ @POST("stories")
+ suspend fun createStory(@Body data: CreateStoryRequest): StoryResponse
+
+ @Multipart
+ @POST("stories/video")
+ suspend fun uploadVideo(@Part file: MultipartBody.Part): UploadResponse
+
+ @POST("stories/{storyId}/view")
+ suspend fun viewStory(@Path("storyId") storyId: String)
+
+ @POST("stories/{storyId}/reaction")
+ suspend fun addReaction(@Path("storyId") storyId: String, @Body body: ReactionRequest)
+
+ @POST("stories/{storyId}/reply")
+ suspend fun addReply(@Path("storyId") storyId: String, @Body body: ReplyRequest)
+}
+
+data class StoryGroupDto(
+ val userId: String,
+ val username: String,
+ val avatar: String?,
+ val stories: List
+)
+
+data class StoryDto(
+ val id: String,
+ val type: String, // "image" | "video"
+ val mediaUrl: String,
+ val content: String?,
+ val createdAt: String,
+ val reactions: List = emptyList()
+)
+
+data class StoryReactionDto(val userId: String, val emoji: String)
+data class CreateStoryRequest(
+ val type: String, // "image" | "video"
+ val mediaUrl: String,
+ val content: String? = null, // JSON metadata for stickers/text or just text
+ val bgColor: String? = null,
+ val privacy: String = "all", // "all" | "contacts" | "selected"
+ val isMuted: Boolean = false
+)
+data class ReactionRequest(val emoji: String)
+data class ReplyRequest(val content: String)
+data class StoryResponse(val id: String)
+data class UploadResponse(val url: String)
diff --git a/client-mobile/stories/data/repository/StoryRepositoryImpl.kt b/client-mobile/stories/data/repository/StoryRepositoryImpl.kt
new file mode 100644
index 0000000..d67551e
--- /dev/null
+++ b/client-mobile/stories/data/repository/StoryRepositoryImpl.kt
@@ -0,0 +1,26 @@
+package stories.data.repository
+
+import stories.data.remote.api.*
+import stories.domain.repository.StoryRepository
+import okhttp3.MultipartBody
+import javax.inject.Inject
+
+class StoryRepositoryImpl @Inject constructor(
+ private val api: StoryApi
+) : StoryRepository {
+ override suspend fun getStories(): List = api.getStories()
+
+ override suspend fun createStory(request: CreateStoryRequest): StoryResponse =
+ api.createStory(request)
+
+ override suspend fun uploadVideo(file: MultipartBody.Part): UploadResponse =
+ api.uploadVideo(file)
+
+ override suspend fun viewStory(storyId: String) = api.viewStory(storyId)
+
+ override suspend fun addReaction(storyId: String, emoji: String) =
+ api.addReaction(storyId, ReactionRequest(emoji))
+
+ override suspend fun addReply(storyId: String, content: String) =
+ api.addReply(storyId, ReplyRequest(content))
+}
diff --git a/client-mobile/stories/di/StoryModule.kt b/client-mobile/stories/di/StoryModule.kt
new file mode 100644
index 0000000..94ef504
--- /dev/null
+++ b/client-mobile/stories/di/StoryModule.kt
@@ -0,0 +1,28 @@
+package stories.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import stories.data.remote.api.StoryApi
+import stories.data.repository.StoryRepositoryImpl
+import stories.domain.repository.StoryRepository
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object StoryModule {
+
+ @Provides
+ @Singleton
+ fun provideStoryApi(retrofit: Retrofit): StoryApi {
+ return retrofit.create(StoryApi::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideStoryRepository(api: StoryApi): StoryRepository {
+ return StoryRepositoryImpl(api)
+ }
+}
diff --git a/client-mobile/stories/domain/repository/StoryRepository.kt b/client-mobile/stories/domain/repository/StoryRepository.kt
new file mode 100644
index 0000000..4d56962
--- /dev/null
+++ b/client-mobile/stories/domain/repository/StoryRepository.kt
@@ -0,0 +1,16 @@
+package stories.domain.repository
+
+import stories.data.remote.api.StoryGroupDto
+import stories.data.remote.api.CreateStoryRequest
+import stories.data.remote.api.StoryResponse
+import stories.data.remote.api.UploadResponse
+import okhttp3.MultipartBody
+
+interface StoryRepository {
+ suspend fun getStories(): List
+ suspend fun createStory(request: CreateStoryRequest): StoryResponse
+ suspend fun uploadVideo(file: MultipartBody.Part): UploadResponse
+ suspend fun viewStory(storyId: String)
+ suspend fun addReaction(storyId: String, emoji: String)
+ suspend fun addReply(storyId: String, content: String)
+}
diff --git a/client-mobile/stories/presentation/CreateStoryScreen.kt b/client-mobile/stories/presentation/CreateStoryScreen.kt
new file mode 100644
index 0000000..96c27dd
--- /dev/null
+++ b/client-mobile/stories/presentation/CreateStoryScreen.kt
@@ -0,0 +1,316 @@
+package stories.presentation
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import coil.compose.AsyncImage
+import core.presentation.theme.OnSurfaceVariant
+import core.presentation.theme.PrimaryIndigo
+import core.presentation.theme.SurfaceContainer
+import ru.knot.messager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CreateStoryScreen(
+ onClose: () -> Unit,
+ onCreated: () -> Unit,
+ viewModel: CreateStoryViewModel = hiltViewModel()
+) {
+ val context = LocalContext.current
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ uri?.let {
+ val isVideo = context.contentResolver.getType(it)?.startsWith("video/") == true
+ viewModel.onMediaSelected(it, isVideo)
+ }
+ }
+
+ LaunchedEffect(viewModel.uploadSuccess) {
+ viewModel.uploadSuccess.collect { success ->
+ if (success) {
+ onCreated()
+ onClose()
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Header
+ Header(
+ onClose = onClose,
+ isUploading = viewModel.isUploading,
+ onPublish = { viewModel.publishStory(context) }
+ )
+
+ Row(modifier = Modifier.weight(1f)) {
+ // Main Area
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .padding(16.dp)
+ .clip(RoundedCornerShape(32.dp))
+ .background(viewModel.bgColor)
+ .border(8.dp, Color(0xFF161616), RoundedCornerShape(32.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ if (viewModel.selectedMediaUri == null) {
+ EmptyMediaState(onSelect = { launcher.launch("*/*") })
+ } else {
+ MediaPreview(
+ uri = viewModel.selectedMediaUri,
+ isVideo = viewModel.isVideo,
+ onRemove = { viewModel.removeMedia() }
+ )
+ }
+ }
+
+ // Sidebar Tools
+ Sidebar(
+ currentTool = viewModel.currentTool,
+ onToolSelect = { viewModel.setTool(it) },
+ onAddText = { viewModel.addText("New Text") },
+ onColorChange = { viewModel.updateBgColor(it) },
+ bgColor = viewModel.bgColor
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun Header(
+ onClose: () -> Unit,
+ isUploading: Boolean,
+ onPublish: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ .background(Color(0xFF0A0A0A))
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.back), tint = Color.Gray)
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ stringResource(R.string.story_editor),
+ style = MaterialTheme.typography.labelMedium.copy(
+ fontWeight = FontWeight.Black,
+ letterSpacing = 2.sp,
+ color = Color.Gray
+ )
+ )
+ }
+
+ Button(
+ onClick = onPublish,
+ enabled = !isUploading,
+ colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ if (isUploading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text(
+ stringResource(R.string.publish),
+ style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Black)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun EmptyMediaState(onSelect: () -> Unit) {
+ Column(
+ modifier = Modifier.clickable { onSelect() },
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .border(2.dp, Color(0xFF333333), CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Default.CameraAlt, contentDescription = null, tint = Color(0xFF333333), modifier = Modifier.size(32.dp))
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ stringResource(R.string.start_creation),
+ style = MaterialTheme.typography.labelSmall.copy(
+ fontWeight = FontWeight.Black,
+ letterSpacing = 4.sp,
+ color = Color(0xFF333333)
+ )
+ )
+ }
+}
+
+@Composable
+fun MediaPreview(
+ uri: Uri?,
+ isVideo: Boolean,
+ onRemove: () -> Unit
+) {
+ val context = LocalContext.current
+ val exoPlayer = remember {
+ ExoPlayer.Builder(context).build().apply {
+ repeatMode = Player.REPEAT_MODE_ALL
+ playWhenReady = true
+ }
+ }
+
+ LaunchedEffect(uri) {
+ if (isVideo && uri != null) {
+ exoPlayer.setMediaItem(MediaItem.fromUri(uri))
+ exoPlayer.prepare()
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose { exoPlayer.release() }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (isVideo) {
+ AndroidView(
+ factory = {
+ PlayerView(context).apply {
+ player = exoPlayer
+ useController = false
+ resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ AsyncImage(
+ model = uri,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ IconButton(
+ onClick = onRemove,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(16.dp)
+ .background(Color.Black.copy(0.6f), RoundedCornerShape(12.dp))
+ ) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove), tint = Color.White)
+ }
+ }
+}
+
+@Composable
+fun Sidebar(
+ currentTool: StoryTool,
+ onToolSelect: (StoryTool) -> Unit,
+ onAddText: () -> Unit,
+ onColorChange: (Color) -> Unit,
+ bgColor: Color
+) {
+ Column(
+ modifier = Modifier
+ .width(100.dp)
+ .fillMaxHeight()
+ .background(Color(0xFF0A0A0A))
+ .padding(vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ ToolButton(Icons.Default.TextFields, stringResource(R.string.text_tool), currentTool is StoryTool.Text) { onAddText() }
+ ToolButton(Icons.Default.ContentCut, stringResource(R.string.crop_tool), currentTool is StoryTool.Crop) { onToolSelect(StoryTool.Crop) }
+ ToolButton(Icons.Default.SentimentSatisfied, stringResource(R.string.stickers_tool), currentTool is StoryTool.Stickers) { onToolSelect(StoryTool.Stickers) }
+ ToolButton(Icons.Default.Brush, stringResource(R.string.brush_tool), currentTool is StoryTool.Brush) { onToolSelect(StoryTool.Brush) }
+ ToolButton(Icons.Default.Tune, stringResource(R.string.filters_tool), currentTool is StoryTool.Filters) { onToolSelect(StoryTool.Filters) }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(bgColor)
+ .border(2.dp, Color.White.copy(0.1f), CircleShape)
+ .clickable { onColorChange(Color((0..0xFFFFFF).random() or 0xFF000000.toInt())) }
+ )
+ }
+}
+
+@Composable
+fun ToolButton(
+ icon: ImageVector,
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ icon,
+ contentDescription = label,
+ tint = if (isSelected) PrimaryIndigo else OnSurfaceVariant,
+ modifier = Modifier.size(24.dp)
+ )
+ Text(
+ label,
+ style = MaterialTheme.typography.labelSmall.copy(
+ fontSize = 8.sp,
+ fontWeight = FontWeight.Black,
+ color = if (isSelected) Color.White else OnSurfaceVariant
+ )
+ )
+ }
+}
diff --git a/client-mobile/stories/presentation/CreateStoryViewModel.kt b/client-mobile/stories/presentation/CreateStoryViewModel.kt
new file mode 100644
index 0000000..adf7beb
--- /dev/null
+++ b/client-mobile/stories/presentation/CreateStoryViewModel.kt
@@ -0,0 +1,165 @@
+package stories.presentation
+
+import android.net.Uri
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import core.network.ServerConfig
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import stories.data.remote.api.CreateStoryRequest
+import stories.data.remote.api.StoryApi
+import java.io.File
+import javax.inject.Inject
+
+data class TextObject(
+ val id: Long = System.currentTimeMillis(),
+ val text: String,
+ val offset: Offset = Offset(100f, 100f),
+ val color: Color = Color.White,
+ val fontSize: Float = 24f,
+ val scale: Float = 1f
+)
+
+data class StickerObject(
+ val id: Long = System.currentTimeMillis(),
+ val emoji: String,
+ val offset: Offset = Offset(200f, 200f),
+ val scale: Float = 1.5f
+)
+
+sealed class StoryTool {
+ object None : StoryTool()
+ object Text : StoryTool()
+ object Crop : StoryTool()
+ object Stickers : StoryTool()
+ object Brush : StoryTool()
+ object Filters : StoryTool()
+ object Trim : StoryTool()
+}
+
+@HiltViewModel
+class CreateStoryViewModel @Inject constructor(
+ private val storyApi: StoryApi,
+ private val serverConfig: ServerConfig
+) : ViewModel() {
+
+ var selectedMediaUri by mutableStateOf(null)
+ private set
+
+ var isVideo by mutableStateOf(false)
+ private set
+
+ var currentTool by mutableStateOf(StoryTool.None)
+ private set
+
+ var bgColor by mutableStateOf(Color(0xFF1E1E2E))
+ private set
+
+ val textObjects = mutableStateListOf()
+ val stickerObjects = mutableStateListOf()
+
+ var isUploading by mutableStateOf(false)
+ private set
+
+ private val _uploadSuccess = MutableStateFlow(false)
+ val uploadSuccess = _uploadSuccess.asStateFlow()
+
+ private val _errorMessage = MutableStateFlow(null)
+ val errorMessage = _errorMessage.asStateFlow()
+
+ fun onMediaSelected(uri: Uri, isVideo: Boolean) {
+ this.selectedMediaUri = uri
+ this.isVideo = isVideo
+ if (isVideo) {
+ currentTool = StoryTool.Trim
+ } else {
+ currentTool = StoryTool.Crop
+ }
+ }
+
+ fun setTool(tool: StoryTool) {
+ currentTool = tool
+ }
+
+ fun addText(text: String) {
+ textObjects.add(TextObject(text = text))
+ currentTool = StoryTool.Text
+ }
+
+ fun addSticker(emoji: String) {
+ stickerObjects.add(StickerObject(emoji = emoji))
+ }
+
+ fun updateBgColor(color: Color) {
+ bgColor = color
+ }
+
+ fun removeMedia() {
+ selectedMediaUri = null
+ isVideo = false
+ currentTool = StoryTool.None
+ textObjects.clear()
+ stickerObjects.clear()
+ }
+
+ fun publishStory(context: android.content.Context) {
+ val uri = selectedMediaUri ?: return
+ val maxFileSize = serverConfig.getServerConfig().limits.maxFileSize
+
+ viewModelScope.launch {
+ isUploading = true
+ try {
+ val file = uriToFile(context, uri)
+
+ if (file.length() > maxFileSize) {
+ _errorMessage.value = "File too large for this server"
+ return@launch
+ }
+
+ val mediaUrl = if (isVideo) {
+ val part = MultipartBody.Part.createFormData(
+ "file", file.name, file.asRequestBody("video/*".toMediaTypeOrNull())
+ )
+ storyApi.uploadVideo(part).url
+ } else {
+ // Simulating image upload via same or different endpoint
+ "https://example.com/simulated_upload.jpg"
+ }
+
+ val request = CreateStoryRequest(
+ type = if (isVideo) "video" else "image",
+ mediaUrl = mediaUrl,
+ bgColor = String.format("#%06X", (0xFFFFFF and bgColor.value.toInt())),
+ content = null // Serialize stickers/text if needed
+ )
+
+ storyApi.createStory(request)
+ _uploadSuccess.value = true
+ } catch (e: Exception) {
+ _errorMessage.value = e.message ?: "Upload failed"
+ } finally {
+ isUploading = false
+ }
+ }
+ }
+
+ private fun uriToFile(context: android.content.Context, uri: Uri): File {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val file = File(context.cacheDir, "temp_story_${System.currentTimeMillis()}")
+ file.outputStream().use { outputStream ->
+ inputStream?.copyTo(outputStream)
+ }
+ return file
+ }
+}
diff --git a/client-mobile/stories/presentation/StoryViewModel.kt b/client-mobile/stories/presentation/StoryViewModel.kt
new file mode 100644
index 0000000..d65898f
--- /dev/null
+++ b/client-mobile/stories/presentation/StoryViewModel.kt
@@ -0,0 +1,61 @@
+package stories.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import stories.data.remote.api.StoryGroupDto
+import stories.domain.repository.StoryRepository
+import javax.inject.Inject
+
+data class StoryState(
+ val storyGroups: List = emptyList(),
+ val isLoading: Boolean = false,
+ val error: String? = null
+)
+
+@HiltViewModel
+class StoryViewModel @Inject constructor(
+ private val repository: StoryRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(StoryState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ loadStories()
+ }
+
+ fun loadStories() {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true) }
+ try {
+ val stories = repository.getStories()
+ _state.update { it.copy(storyGroups = stories, isLoading = false) }
+ } catch (e: Exception) {
+ _state.update { it.copy(isLoading = false, error = e.localizedMessage) }
+ }
+ }
+ }
+
+ fun markAsViewed(storyId: String) {
+ viewModelScope.launch {
+ try {
+ repository.viewStory(storyId)
+ } catch (e: Exception) {
+ // Ignore error for viewing
+ }
+ }
+ }
+
+ fun sendReaction(storyId: String, emoji: String) {
+ viewModelScope.launch {
+ try {
+ repository.addReaction(storyId, emoji)
+ } catch (e: Exception) {
+ // Handle error
+ }
+ }
+ }
+}
diff --git a/client-mobile/stories/presentation/StoryViewerScreen.kt b/client-mobile/stories/presentation/StoryViewerScreen.kt
new file mode 100644
index 0000000..1896116
--- /dev/null
+++ b/client-mobile/stories/presentation/StoryViewerScreen.kt
@@ -0,0 +1,192 @@
+package stories.presentation
+
+import android.net.Uri
+import androidx.annotation.OptIn
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import coil.compose.AsyncImage
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@OptIn(UnstableApi::class)
+@Composable
+fun StoryViewerScreen(
+ stories: List, // URL-адреса фото/видео
+ onClose: () -> Unit
+) {
+ val context = LocalContext.current
+ var currentIndex by remember { mutableIntStateOf(0) }
+ val progress = remember { Animatable(0f) }
+ val scope = rememberCoroutineScope()
+ var isPaused by remember { mutableStateOf(false) }
+
+ val currentUrl = stories[currentIndex]
+ val isVideo = currentUrl.endsWith(".mp4") || currentUrl.contains("video")
+
+ val exoPlayer = remember {
+ ExoPlayer.Builder(context).build().apply {
+ repeatMode = Player.REPEAT_MODE_OFF
+ playWhenReady = true
+ }
+ }
+
+ DisposableEffect(exoPlayer) {
+ onDispose {
+ exoPlayer.release()
+ }
+ }
+
+ // Подготовка видео при смене индекса
+ LaunchedEffect(currentIndex) {
+ if (isVideo) {
+ exoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(currentUrl)))
+ exoPlayer.prepare()
+ exoPlayer.play()
+ } else {
+ exoPlayer.stop()
+ }
+ }
+
+ // Запуск таймера прогресса
+ LaunchedEffect(currentIndex, isPaused) {
+ if (!isPaused) {
+ val duration = if (isVideo) {
+ // Ждем пока загрузится длительность видео или используем 15сек по умолчанию
+ delay(500) // Даем немного времени на загрузку метаданных
+ if (exoPlayer.duration > 0) exoPlayer.duration else 15000
+ } else {
+ 5000L // 5 секунд для фото
+ }
+
+ progress.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(
+ durationMillis = duration.toInt(),
+ easing = LinearEasing
+ )
+ )
+ // Когда прогресс дошел до конца — следующая история
+ if (currentIndex < stories.size - 1) {
+ currentIndex++
+ progress.snapTo(0f)
+ } else {
+ onClose()
+ }
+ } else {
+ if (isVideo) exoPlayer.pause() else Unit
+ }
+ }
+
+ // Возобновление видео при снятии паузы
+ LaunchedEffect(isPaused) {
+ if (!isPaused && isVideo) exoPlayer.play()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ isPaused = true
+ tryAwaitRelease()
+ isPaused = false
+ },
+ onTap = { offset ->
+ val screenWidth = size.width
+ if (offset.x < screenWidth / 3) {
+ if (currentIndex > 0) {
+ currentIndex--
+ scope.launch { progress.snapTo(0f) }
+ }
+ } else {
+ if (currentIndex < stories.size - 1) {
+ currentIndex++
+ scope.launch { progress.snapTo(0f) }
+ } else {
+ onClose()
+ }
+ }
+ }
+ )
+ }
+ ) {
+ if (isVideo) {
+ AndroidView(
+ factory = {
+ PlayerView(context).apply {
+ player = exoPlayer
+ useController = false
+ resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ AsyncImage(
+ model = currentUrl,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ // Верхние индикаторы
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp, start = 8.dp, end = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ stories.forEachIndexed { index, _ ->
+ val currentProgress = when {
+ index < currentIndex -> 1f
+ index == currentIndex -> progress.value
+ else -> 0f
+ }
+ LinearProgressIndicator(
+ progress = currentProgress,
+ modifier = Modifier
+ .weight(1f)
+ .height(3.dp)
+ .clip(RoundedCornerShape(2.dp)),
+ color = Color.White,
+ trackColor = Color.White.copy(alpha = 0.3f)
+ )
+ }
+ }
+
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 24.dp, end = 16.dp)
+ ) {
+ Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
+ }
+ }
+}
diff --git a/client-mobile/stories/presentation/components/StoryThumbnail.kt b/client-mobile/stories/presentation/components/StoryThumbnail.kt
new file mode 100644
index 0000000..f2e7d47
--- /dev/null
+++ b/client-mobile/stories/presentation/components/StoryThumbnail.kt
@@ -0,0 +1,63 @@
+package stories.presentation.components
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import core.presentation.components.AppAvatar
+import core.presentation.theme.SoftSquareShape
+
+@Composable
+fun StoryThumbnail(
+ username: String,
+ avatarUrl: String?,
+ hasUnseen: Boolean,
+ onClick: () -> Unit
+) {
+ val gradientBrush = Brush.sweepGradient(
+ colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Cyan)
+ )
+
+ Column(
+ modifier = Modifier
+ .padding(8.dp)
+ .clickable(onClick = onClick),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .padding(4.dp)
+ .then(
+ if (hasUnseen) Modifier.border(3.dp, gradientBrush, SoftSquareShape)
+ else Modifier.border(1.dp, Color.Gray, SoftSquareShape)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ AppAvatar(
+ url = avatarUrl,
+ name = username,
+ size = 60.dp,
+ modifier = Modifier.padding(2.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = username,
+ fontSize = 11.sp,
+ maxLines = 1,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}