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 + ) + } +}