diff --git a/client-mobile/.gitkeep b/client-mobile/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/client-mobile/app/build.gradle.kts b/client-mobile/app/build.gradle.kts deleted file mode 100644 index 49279eb..0000000 --- a/client-mobile/app/build.gradle.kts +++ /dev/null @@ -1,146 +0,0 @@ -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" - } - // Включаем оптимизации компилятора Compose - kotlinOptions { - jvmTarget = "17" - } - 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") - implementation("androidx.hilt:hilt-work:1.1.0") - kapt("androidx.hilt:hilt-compiler: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") - implementation("io.coil-kt:coil-video: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") - - // Room - val room_version = "2.6.1" - implementation("androidx.room:room-runtime:$room_version") - implementation("androidx.room:room-ktx:$room_version") - kapt("androidx.room:room-compiler:$room_version") - - // WorkManager - val work_version = "2.9.0" - implementation("androidx.work:work-runtime-ktx:$work_version") - - // Paging 3 - val paging_version = "3.3.0" - implementation("androidx.paging:paging-runtime-ktx:$paging_version") - implementation("androidx.paging:paging-compose:$paging_version") - - // 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 deleted file mode 100644 index cbd9564..0000000 --- a/client-mobile/app/google-services.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index d06a980..0000000 --- a/client-mobile/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index 9fa09f0..0000000 --- a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -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.KnotTheme - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - @javax.inject.Inject - lateinit var navigationManager: core.utils.NavigationManager - - @javax.inject.Inject - lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - signalrNotificationObserver.start() - - intent.getStringExtra("chatId")?.let { chatId -> - navigationManager.navigateToChat(chatId) - } - - // Request notifications permission for Android 13+ - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - androidx.core.app.ActivityCompat.requestPermissions( - this, - arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), - 101 - ) - } - - setContent { - KnotTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - AppNavigation(navigationManager = navigationManager) - } - } - } - } - - override fun onNewIntent(intent: android.content.Intent?) { - super.onNewIntent(intent) - intent?.getStringExtra("chatId")?.let { chatId -> - navigationManager.navigateToChat(chatId) - } - } -} 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 deleted file mode 100644 index d1415f3..0000000 --- a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.knot.messenger - -import android.app.Application -import androidx.hilt.work.HiltWorkerFactory -import androidx.work.Configuration -import coil.ImageLoader -import coil.ImageLoaderFactory -import coil.decode.VideoFrameDecoder -import dagger.hilt.android.HiltAndroidApp -import javax.inject.Inject - -@HiltAndroidApp -class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider { - - @Inject - lateinit var workerFactory: HiltWorkerFactory - - override val workManagerConfiguration: Configuration - get() = Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() - - override fun newImageLoader(): ImageLoader { - return ImageLoader.Builder(this) - .components { - add(VideoFrameDecoder.Factory()) - } - .build() - } -} diff --git a/client-mobile/app/src/main/res/drawable/ic_notification.xml b/client-mobile/app/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index f99d835..0000000 --- a/client-mobile/app/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/client-mobile/app/src/main/res/mipmap-hdpi/ic_launcher.png b/client-mobile/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 09264f7..0000000 Binary files a/client-mobile/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/client-mobile/app/src/main/res/mipmap-mdpi/ic_launcher.png b/client-mobile/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index e6ae1b2..0000000 Binary files a/client-mobile/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/client-mobile/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/client-mobile/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 5e867ef..0000000 Binary files a/client-mobile/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/client-mobile/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/client-mobile/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index bc05396..0000000 Binary files a/client-mobile/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/client-mobile/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/client-mobile/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 1af648c..0000000 Binary files a/client-mobile/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/client-mobile/app/src/main/res/values-en/strings.xml b/client-mobile/app/src/main/res/values-en/strings.xml deleted file mode 100644 index cf42e3d..0000000 --- a/client-mobile/app/src/main/res/values-en/strings.xml +++ /dev/null @@ -1,63 +0,0 @@ - - Knot - 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 - Photo - Video - Audio - File - GIF - Reply to - yourself - diff --git a/client-mobile/app/src/main/res/values/strings.xml b/client-mobile/app/src/main/res/values/strings.xml deleted file mode 100644 index d9ea064..0000000 --- a/client-mobile/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,85 +0,0 @@ - - Knot - Войти - Регистрация - Имя пользователя - Пароль - Отображаемое имя - Настройки - Сохранить - Назад - Подключение к серверу - API Base URL - Функции сервера - Истории - Опросы - Звонки - Группы - Включено - Отключено - Лимиты - Макс. размер файла - Макс. участников в группе - Сообщение - Позвонить - Заблокировать - Профиль - Подтвердите пароль - Пароли не совпадают - Нет аккаунта? Зарегистрироваться - Уже есть аккаунт? Войти - Произошла ошибка - Загрузка... - Чаты - Контакты - Истории - Создать историю - Напишите сообщение... - Ответить %1$s... - РЕДАКТОР ИСТОРИЙ - ОПУБЛИКОВАТЬ - НАЧАТЬ СОЗДАНИЕ - ТЕКСТ - ОБРЕЗКА - СТИКЕРЫ - КИСТЬ - ФИЛЬТРЫ - Удалить - Чаты не найдены - печатает... - Видеозвонок - Эмодзи - Прикрепить - Сообщение... - Голосовое сообщение - Отправить - Поиск... - В сети - Был(а): %1$s - недавно - Все - Онлайн - Заблокированные - Медиа - Уведомления - Без звука - Включить звук - Выйти из аккаунта - О себе - Редактировать профиль - Имя пользователя - Изменить фото - Отмена - Обрезать - Чаты - Контакты - Профиль - Сохранение... - Фото - Видео - Аудио - Файл - GIF - Ответ - самому себе - diff --git a/client-mobile/app/src/main/res/values/styles.xml b/client-mobile/app/src/main/res/values/styles.xml deleted file mode 100644 index 3be2708..0000000 --- a/client-mobile/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/client-mobile/app/src/main/res/xml/network_security_config.xml b/client-mobile/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 29e5665..0000000 --- a/client-mobile/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - localhost - 127.0.0.1 - 10.0.0.0/8 - 172.16.0.0/12 - 192.168.0.0/16 - - diff --git a/client-mobile/auth/data/remote/api/AuthApi.kt b/client-mobile/auth/data/remote/api/AuthApi.kt deleted file mode 100644 index 07a5104..0000000 --- a/client-mobile/auth/data/remote/api/AuthApi.kt +++ /dev/null @@ -1,29 +0,0 @@ -package auth.data.remote.api - -import auth.data.remote.dto.AuthRequest -import auth.data.remote.dto.AuthResponse -import auth.data.remote.dto.RefreshTokenRequest -import core.domain.model.ServerConfigModel -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.Headers -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 - - @POST("auth/refresh") - suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse - - @GET("config") - @Headers("Cache-Control: no-cache") - suspend fun getConfig(): ServerConfigModel - - @POST("auth/push-token") - @Headers("Cache-Control: no-cache") - 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 deleted file mode 100644 index cac9089..0000000 --- a/client-mobile/auth/data/remote/dto/AuthDtos.kt +++ /dev/null @@ -1,28 +0,0 @@ -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("refreshToken") val refreshToken: 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? -) - -data class RefreshTokenRequest( - @SerializedName("refreshToken") val refreshToken: String -) diff --git a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt deleted file mode 100644 index 8b6fae5..0000000 --- a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt +++ /dev/null @@ -1,131 +0,0 @@ -package auth.data.repository - -import auth.data.remote.api.AuthApi -import auth.data.remote.dto.AuthRequest -import auth.data.remote.dto.RefreshTokenRequest -import auth.domain.model.AuthResult -import auth.domain.repository.AuthRepository -import core.network.ServerConfig -import core.security.TokenManager -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject - -class AuthRepositoryImpl @Inject constructor( - private val api: AuthApi, - private val tokenManager: TokenManager, - private val serverConfig: ServerConfig -) : AuthRepository { - - private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null) - - 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")) - val userId = response.userId ?: "" - - tokenManager.saveToken(token, userId, response.refreshToken) - _isAuthenticated.value = true - fetchConfig() - Result.success( - AuthResult( - token = token, - refreshToken = response.refreshToken, - userId = 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")) - val userId = response.userId ?: "" - - tokenManager.saveToken(token, userId, response.refreshToken) - _isAuthenticated.value = true - fetchConfig() - Result.success( - AuthResult( - token = token, - refreshToken = response.refreshToken, - userId = 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() - _isAuthenticated.value = false - } - - override fun isAuthenticated(): Boolean { - return _isAuthenticated.value - } - - override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow { - return _isAuthenticated.asStateFlow() - } - - override suspend fun fetchConfig(): Result { - return try { - val config = api.getConfig() - serverConfig.saveServerConfig(config) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun updatePushToken(token: String) { - try { - api.updatePushToken(token) - } catch (e: Exception) { - // Silent fail - } - } - - override suspend fun refreshToken(): Result { - val currentRefreshToken = tokenManager.getRefreshToken() - if (currentRefreshToken == null) { - return Result.failure(Exception("Refresh token is null")) - } - - return try { - val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken)) - val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null")) - val newRefreshToken = response.refreshToken - val userId = response.userId ?: "" - - tokenManager.saveToken(newAccessToken, userId, newRefreshToken) - _isAuthenticated.value = true - - Result.success( - AuthResult( - token = newAccessToken, - refreshToken = newRefreshToken, - userId = userId, - userName = response.username ?: "", - displayName = response.displayName ?: "", - avatarUrl = null - ) - ) - } catch (e: Exception) { - Result.failure(e) - } - } -} diff --git a/client-mobile/auth/di/AuthModule.kt b/client-mobile/auth/di/AuthModule.kt deleted file mode 100644 index 3c24d67..0000000 --- a/client-mobile/auth/di/AuthModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 06ac946..0000000 --- a/client-mobile/auth/domain/model/AuthModels.kt +++ /dev/null @@ -1,10 +0,0 @@ -package auth.domain.model - -data class AuthResult( - val token: String, - val refreshToken: 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 deleted file mode 100644 index 781d4a7..0000000 --- a/client-mobile/auth/domain/repository/AuthRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 - fun isAuthenticated(): Boolean - fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow - suspend fun updatePushToken(token: String) - suspend fun refreshToken(): Result -} diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt deleted file mode 100644 index be8ab94..0000000 --- a/client-mobile/auth/presentation/AuthViewModel.kt +++ /dev/null @@ -1,94 +0,0 @@ -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 com.google.firebase.messaging.FirebaseMessaging -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(isAuthenticated = repository.isAuthenticated())) - val state: StateFlow = _state.asStateFlow() - - init { - viewModelScope.launch { - repository.isAuthenticatedFlow().collect { authenticated -> - _state.update { it.copy(isAuthenticated = authenticated) } - if (authenticated) { - updatePushToken() - } - } - } - } - - fun checkAuth() { - _state.update { it.copy(isAuthenticated = repository.isAuthenticated()) } - } - - 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) } - updatePushToken() - } - .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) } - updatePushToken() - } - .onFailure { e -> - _state.update { it.copy(isLoading = false, error = e.message) } - } - } - } - - fun refreshToken() { - viewModelScope.launch { - repository.refreshToken() - .onSuccess { - // Token refreshed successfully - } - .onFailure { - // Refresh failed, will trigger logout via AuthInterceptor - } - } - } - - private fun updatePushToken() { - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - val token = task.result - viewModelScope.launch { - repository.updatePushToken(token) - } - } - } - } -} diff --git a/client-mobile/auth/presentation/LoginScreen.kt b/client-mobile/auth/presentation/LoginScreen.kt deleted file mode 100644 index a9865bc..0000000 --- a/client-mobile/auth/presentation/LoginScreen.kt +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 61698f5..0000000 --- a/client-mobile/auth/presentation/RegisterScreen.kt +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 70ac46c..0000000 --- a/client-mobile/build.gradle.kts +++ /dev/null @@ -1,8 +0,0 @@ -// 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 deleted file mode 100644 index 6d8c01f..0000000 --- a/client-mobile/calls/data/remote/CallAudioManager.kt +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index ecfdcb0..0000000 --- a/client-mobile/calls/data/remote/GroupWebRtcManager.kt +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 5ca37ea..0000000 --- a/client-mobile/calls/data/remote/WebRtcManager.kt +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 0505c90..0000000 --- a/client-mobile/calls/presentation/CallScreen.kt +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index 8ba9fc8..0000000 --- a/client-mobile/calls/presentation/CallViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index cd5ff8e..0000000 --- a/client-mobile/calls/presentation/GroupCallViewModel.kt +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 5d7fa6a..0000000 --- a/client-mobile/calls/presentation/components/VideoGrid.kt +++ /dev/null @@ -1,67 +0,0 @@ -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/local/dao/ChatDao.kt b/client-mobile/chats/data/local/dao/ChatDao.kt deleted file mode 100644 index b28b825..0000000 --- a/client-mobile/chats/data/local/dao/ChatDao.kt +++ /dev/null @@ -1,45 +0,0 @@ -package chats.data.local.dao - -import androidx.room.* -import chats.data.local.database.ChatEntity -import kotlinx.coroutines.flow.Flow - -/** - * DAO для операций с чатами в Room Database - */ -@Dao -interface ChatDao { - - @Query("SELECT * FROM chats ORDER BY lastMessageTimestamp DESC") - fun getAllChats(): Flow> - - @Query("SELECT * FROM chats WHERE remoteId = :remoteId LIMIT 1") - suspend fun getChatByRemoteId(remoteId: String): ChatEntity? - - @Query("SELECT * FROM chats WHERE localId = :localId LIMIT 1") - suspend fun getChatByLocalId(localId: String): ChatEntity? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertChat(chat: ChatEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertChats(chats: List) - - @Update - suspend fun updateChat(chat: ChatEntity) - - @Query("UPDATE chats SET lastMessageText = :lastMessageText, lastMessageTimestamp = :timestamp WHERE remoteId = :chatId") - suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: Long?) - - @Query("UPDATE chats SET unreadCount = :count WHERE remoteId = :chatId") - suspend fun updateUnreadCount(chatId: String, count: Int) - - @Delete - suspend fun deleteChat(chat: ChatEntity) - - @Query("DELETE FROM chats WHERE remoteId = :remoteId") - suspend fun deleteChatByRemoteId(remoteId: String) - - @Query("DELETE FROM chats") - suspend fun deleteAllChats() -} diff --git a/client-mobile/chats/data/local/dao/MessageDao.kt b/client-mobile/chats/data/local/dao/MessageDao.kt deleted file mode 100644 index 91beb19..0000000 --- a/client-mobile/chats/data/local/dao/MessageDao.kt +++ /dev/null @@ -1,83 +0,0 @@ -package chats.data.local.dao - -import androidx.room.* -import chats.data.local.database.MessageEntity -import chats.domain.model.MessageStatus -import kotlinx.coroutines.flow.Flow - -/** - * DAO для операций с сообщениями в Room Database - */ -@Dao -interface MessageDao { - - @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC") - fun getMessagesByChatId(chatId: String): Flow> - - /** - * Пагинированная загрузка сообщений для Paging 3 - * sequenceId увеличивается от старых к новым, поэтому ORDER BY DESC для отображения newest внизу - */ - @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT :limit OFFSET :offset") - suspend fun getMessagesPaged(chatId: String, offset: Int, limit: Int): List - - @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT 1") - suspend fun getLastMessage(chatId: String): MessageEntity? - - @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC LIMIT 1") - suspend fun getFirstMessage(chatId: String): MessageEntity? - - @Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId > :afterSequenceId ORDER BY sequenceId ASC LIMIT :limit") - suspend fun getMessagesAfter(chatId: String, afterSequenceId: Long, limit: Int): List - - @Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId < :beforeSequenceId ORDER BY sequenceId DESC LIMIT :limit") - suspend fun getMessagesBefore(chatId: String, beforeSequenceId: Long, limit: Int): List - - @Query("SELECT * FROM messages WHERE localId = :localId") - suspend fun getMessageByLocalId(localId: String): MessageEntity? - - @Query("SELECT * FROM messages WHERE serverId = :serverId") - suspend fun getMessageByServerId(serverId: String): MessageEntity? - - @Query("SELECT * FROM messages WHERE status IN (:statuses) AND chatId = :chatId") - suspend fun getMessagesByStatus(chatId: String, statuses: List): List - - @Query("SELECT * FROM messages WHERE status = :status") - fun getMessagesByStatusFlow(status: MessageStatus): Flow> - - @Query("SELECT * FROM messages WHERE status IN (:statuses)") - suspend fun getPendingMessages(statuses: List): List - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertMessage(message: MessageEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertMessages(messages: List) - - @Update - suspend fun updateMessage(message: MessageEntity) - - @Delete - suspend fun deleteMessage(message: MessageEntity) - - @Query("DELETE FROM messages WHERE localId = :localId") - suspend fun deleteMessageByLocalId(localId: String) - - @Query("DELETE FROM messages WHERE chatId = :chatId") - suspend fun deleteMessagesByChatId(chatId: String) - - @Query("UPDATE messages SET status = :status, updatedAtMillis = :updatedAtMillis WHERE localId = :localId") - suspend fun updateMessageStatus(localId: String, status: MessageStatus, updatedAtMillis: Long) - - @Query("UPDATE messages SET serverId = :serverId, status = :status, updatedAtMillis = :updatedAtMillis WHERE localId = :localId") - suspend fun updateMessageWithServerId(localId: String, serverId: String, status: MessageStatus, updatedAtMillis: Long) - - @Query("UPDATE messages SET status = :status, errorMessage = :errorMessage, retryCount = retryCount + 1, updatedAtMillis = :updatedAtMillis WHERE localId = :localId") - suspend fun updateMessageError(localId: String, status: MessageStatus, errorMessage: String, updatedAtMillis: Long) - - @Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND status IN (:statuses)") - suspend fun getUnsentCount(chatId: String, statuses: List): Int - - @Query("SELECT * FROM messages WHERE chatId = :chatId AND status = :status LIMIT 1") - suspend fun getFirstMessageByStatus(chatId: String, status: MessageStatus): MessageEntity? -} diff --git a/client-mobile/chats/data/local/dao/UserProfileDao.kt b/client-mobile/chats/data/local/dao/UserProfileDao.kt deleted file mode 100644 index 41ec972..0000000 --- a/client-mobile/chats/data/local/dao/UserProfileDao.kt +++ /dev/null @@ -1,36 +0,0 @@ -package chats.data.local.dao - -import androidx.room.* -import chats.data.local.database.UserProfileEntity -import kotlinx.coroutines.flow.Flow - -/** - * DAO для операций с профилями пользователей в Room Database - */ -@Dao -interface UserProfileDao { - - @Query("SELECT * FROM user_profile WHERE userId = :userId LIMIT 1") - suspend fun getUserById(userId: String): UserProfileEntity? - - @Query("SELECT * FROM user_profile WHERE userId = :userId") - fun getUserByIdFlow(userId: String): Flow - - @Query("SELECT * FROM user_profile") - fun getAllUsers(): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertUser(user: UserProfileEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertUsers(users: List) - - @Update - suspend fun updateUser(user: UserProfileEntity) - - @Delete - suspend fun deleteUser(user: UserProfileEntity) - - @Query("DELETE FROM user_profile WHERE userId = :userId") - suspend fun deleteUserById(userId: String) -} diff --git a/client-mobile/chats/data/local/database/AppDatabase.kt b/client-mobile/chats/data/local/database/AppDatabase.kt deleted file mode 100644 index 346fec5..0000000 --- a/client-mobile/chats/data/local/database/AppDatabase.kt +++ /dev/null @@ -1,33 +0,0 @@ -package chats.data.local.database - -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import chats.data.local.dao.ChatDao -import chats.data.local.dao.MessageDao -import chats.data.local.dao.UserProfileDao - -/** - * Room Database для локального хранения данных мессенджера - * Реализует паттерн Single Source of Truth - */ -@Database( - entities = [ - MessageEntity::class, - ChatEntity::class, - UserProfileEntity::class - ], - version = 2, - exportSchema = false -) -@TypeConverters(MessageStatusConverter::class) -abstract class AppDatabase : RoomDatabase() { - - abstract fun messageDao(): MessageDao - abstract fun chatDao(): ChatDao - abstract fun userProfileDao(): UserProfileDao - - companion object { - const val DATABASE_NAME = "knot_messenger.db" - } -} diff --git a/client-mobile/chats/data/local/database/ChatEntity.kt b/client-mobile/chats/data/local/database/ChatEntity.kt deleted file mode 100644 index 250d5a8..0000000 --- a/client-mobile/chats/data/local/database/ChatEntity.kt +++ /dev/null @@ -1,39 +0,0 @@ -package chats.data.local.database - -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey - -/** - * Entity для хранения чатов в локальной БД Room - */ -@Entity( - tableName = "chats", - indices = [ - Index(value = ["remoteId"], unique = true) - ] -) -data class ChatEntity( - @PrimaryKey(autoGenerate = false) - val localId: String, - - val remoteId: String?, // ID с сервера - - val type: String, // PRIVATE, GROUP, CHANNEL - - val name: String, - - val avatar: String? = null, - - val unreadCount: Int = 0, - - val lastMessageId: String? = null, - - val lastMessageText: String? = null, - - val lastMessageTimestamp: Long? = null, - - val lastMessageJson: String? = null, - - val updatedAtMillis: Long = System.currentTimeMillis() -) diff --git a/client-mobile/chats/data/local/database/MessageEntity.kt b/client-mobile/chats/data/local/database/MessageEntity.kt deleted file mode 100644 index f458253..0000000 --- a/client-mobile/chats/data/local/database/MessageEntity.kt +++ /dev/null @@ -1,66 +0,0 @@ -package chats.data.local.database - -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey -import chats.domain.model.MessageStatus - -/** - * Entity для хранения сообщений в локальной БД Room - * Используется для офлайн-режима и Single Source of Truth паттерна - */ -@Entity( - tableName = "messages", - indices = [ - Index(value = ["chatId", "sequenceId"]), - Index(value = ["localId"], unique = true), - Index(value = ["serverId"]), - Index(value = ["status"]) - ] -) -data class MessageEntity( - @PrimaryKey(autoGenerate = false) - val localId: String, // UUID генерируется на клиенте при создании - - val serverId: String?, // null для неотправленных сообщений - - val idempotencyKey: String, // UUID v4 для идемпотентности - - val chatId: String, - - val senderId: String, - - val senderName: String, - - val senderAvatar: String? = null, - - val content: String?, - - val sequenceId: Long, - - val createdAt: String, // ISO-8601 формат - - val mediaType: String, - - val mediaJson: String, // JSON список медиа (для Room) - - val reactionsJson: String = "{}", // JSON map эмодзи -> count - - val status: MessageStatus, - - val isPinned: Boolean = false, - - val isForwarded: Boolean = false, - - val forwardedFromName: String? = null, - - val replyToServerId: String? = null, - - val errorMessage: String? = null, - - val retryCount: Int = 0, - - val createdAtMillis: Long = 0, - - val updatedAtMillis: Long = 0 -) diff --git a/client-mobile/chats/data/local/database/MessageStatusConverter.kt b/client-mobile/chats/data/local/database/MessageStatusConverter.kt deleted file mode 100644 index 1977145..0000000 --- a/client-mobile/chats/data/local/database/MessageStatusConverter.kt +++ /dev/null @@ -1,17 +0,0 @@ -package chats.data.local.database - -import androidx.room.TypeConverter -import chats.domain.model.MessageStatus - -/** - * Конвертеры для Room для работы с Enum и другими типами - */ -class MessageStatusConverter { - @TypeConverter - fun fromMessageStatus(status: MessageStatus): String = status.name - - @TypeConverter - fun toMessageStatus(value: String): MessageStatus = runCatching { - MessageStatus.valueOf(value) - }.getOrDefault(MessageStatus.UNKNOWN) -} diff --git a/client-mobile/chats/data/local/database/UserProfileEntity.kt b/client-mobile/chats/data/local/database/UserProfileEntity.kt deleted file mode 100644 index 74ac13b..0000000 --- a/client-mobile/chats/data/local/database/UserProfileEntity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package chats.data.local.database - -import androidx.room.Entity -import androidx.room.PrimaryKey - -/** - * Entity для хранения профиля пользователя в локальной БД Room - */ -@Entity(tableName = "user_profile") -data class UserProfileEntity( - @PrimaryKey - val userId: String, - - val username: String, - - val displayName: String, - - val avatarUrl: String? = null, - - val bio: String? = null, - - val isOnline: Boolean = false, - - val lastSeenMillis: Long = 0, - - val updatedAtMillis: Long = System.currentTimeMillis() -) diff --git a/client-mobile/chats/data/local/mappers/ChatMappers.kt b/client-mobile/chats/data/local/mappers/ChatMappers.kt deleted file mode 100644 index ca2bb34..0000000 --- a/client-mobile/chats/data/local/mappers/ChatMappers.kt +++ /dev/null @@ -1,57 +0,0 @@ -package chats.data.local.mappers - -import chats.data.local.database.ChatEntity -import chats.domain.model.Chat -import com.google.gson.Gson - -private val gson = Gson() - -/** - * Преобразует Domain Chat в Entity - */ -fun Chat.toEntity(): ChatEntity { - val timestamp = this.lastMessage?.createdAt?.let { parseTimestamp(it) } - return ChatEntity( - localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(), - remoteId = this.id, - type = this.type, - name = this.name, - avatar = this.avatar, - unreadCount = this.unreadCount, - lastMessageText = this.lastMessage?.content, - lastMessageTimestamp = timestamp, - lastMessageJson = this.lastMessage?.let { gson.toJson(it) } - ) -} - -/** - * Преобразует Entity в Domain Chat - */ -fun ChatEntity.toDomain(): Chat { - val lastMessage = this.lastMessageJson?.let { json -> - try { - gson.fromJson(json, chats.domain.model.Message::class.java) - } catch (e: Exception) { - null - } - } - return Chat( - id = this.remoteId ?: this.localId, - type = this.type, - name = this.name, - avatar = this.avatar, - unreadCount = this.unreadCount, - lastMessage = lastMessage - ) -} - -/** - * Парсит ISO-8601 timestamp в Long (millis) - */ -private fun parseTimestamp(isoTimestamp: String): Long? { - return try { - java.time.ZonedDateTime.parse(isoTimestamp).toInstant().toEpochMilli() - } catch (e: Exception) { - null - } -} diff --git a/client-mobile/chats/data/local/mappers/MessageMappers.kt b/client-mobile/chats/data/local/mappers/MessageMappers.kt deleted file mode 100644 index 328c954..0000000 --- a/client-mobile/chats/data/local/mappers/MessageMappers.kt +++ /dev/null @@ -1,117 +0,0 @@ -package chats.data.local.mappers - -import chats.data.local.database.MessageEntity -import chats.domain.model.Message -import chats.domain.model.MessageStatus -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken - -private val gson = Gson() - -/** - * Преобразует Domain Message в Entity для сохранения в Room - */ -fun Message.toEntity(status: MessageStatus = MessageStatus.SENT): MessageEntity { - return MessageEntity( - localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(), - serverId = if (status == MessageStatus.SENT || status == MessageStatus.DELIVERED || status == MessageStatus.READ) this.id else null, - idempotencyKey = java.util.UUID.randomUUID().toString(), - chatId = this.chatId, - senderId = this.senderId, - senderName = this.senderName, - senderAvatar = this.senderAvatar, - content = this.content, - sequenceId = this.sequenceId.toLong(), - createdAt = this.createdAt, - mediaType = this.mediaType.name, - mediaJson = gson.toJson(this.media), - reactionsJson = gson.toJson(this.reactions), - status = status, - isPinned = this.isPinned, - isForwarded = this.isForwarded, - forwardedFromName = this.forwardedFromName, - replyToServerId = this.replyTo?.id, - createdAtMillis = parseTimestamp(this.createdAt), - updatedAtMillis = System.currentTimeMillis() - ) -} - -/** - * Преобразует Entity в Domain Message для отображения в UI - */ -fun MessageEntity.toDomain(): Message { - val mediaListType = object : TypeToken>() {}.type - val reactionsMapType = object : TypeToken>() {}.type - - return Message( - id = this.serverId ?: this.localId, - chatId = this.chatId, - senderId = this.senderId, - senderName = this.senderName, - senderAvatar = this.senderAvatar, - content = this.content, - sequenceId = this.sequenceId.toInt(), - createdAt = this.createdAt, - media = try { - gson.fromJson>(this.mediaJson, mediaListType) ?: emptyList() - } catch (e: Exception) { - emptyList() - }, - mediaType = try { - chats.domain.model.MediaType.valueOf(this.mediaType) - } catch (e: Exception) { - chats.domain.model.MediaType.TEXT - }, - reactions = try { - gson.fromJson>(this.reactionsJson, reactionsMapType) ?: emptyMap() - } catch (e: Exception) { - emptyMap() - }, - isRead = this.status == MessageStatus.READ, - isPinned = this.isPinned, - isForwarded = this.isForwarded, - forwardedFromName = this.forwardedFromName, - replyTo = null, // replyTo загружается отдельно если нужно - status = this.status - ) -} - -/** - * Создает новое локальное сообщение со статусом PENDING - */ -fun createPendingMessageEntity( - chatId: String, - senderId: String, - senderName: String, - content: String?, - mediaType: String = "TEXT", - mediaJson: String = "[]", - replyToId: String? = null -): MessageEntity { - val localId = java.util.UUID.randomUUID().toString() - return MessageEntity( - localId = localId, - serverId = null, - idempotencyKey = localId, // Используем localId как idempotency key - chatId = chatId, - senderId = senderId, - senderName = senderName, - content = content, - sequenceId = -1, // Будет обновлено после получения с сервера - createdAt = java.time.ZonedDateTime.now().toString(), - mediaType = mediaType, - mediaJson = mediaJson, - status = MessageStatus.PENDING, - replyToServerId = replyToId, - createdAtMillis = System.currentTimeMillis(), - updatedAtMillis = System.currentTimeMillis() - ) -} - -private fun parseTimestamp(createdAt: String): Long { - return try { - java.time.ZonedDateTime.parse(createdAt).toInstant().toEpochMilli() - } catch (e: Exception) { - System.currentTimeMillis() - } -} diff --git a/client-mobile/chats/data/local/paging/MessagePagingSource.kt b/client-mobile/chats/data/local/paging/MessagePagingSource.kt deleted file mode 100644 index c39fcb4..0000000 --- a/client-mobile/chats/data/local/paging/MessagePagingSource.kt +++ /dev/null @@ -1,43 +0,0 @@ -package chats.data.local.paging - -import androidx.paging.PagingSource -import androidx.paging.PagingState -import chats.data.local.dao.MessageDao -import chats.data.local.database.MessageEntity - -/** - * PagingSource для загрузки сообщений из Room Database - */ -class MessagePagingSource( - private val chatId: String, - private val messageDao: MessageDao -) : PagingSource() { - - companion object { - private const val PAGE_SIZE = 30 - } - - override suspend fun load(params: LoadParams): LoadResult { - return try { - val position = params.key ?: 0 // Начинаем с 0 - - val messages = messageDao.getMessagesPaged( - chatId = chatId, - offset = position, - limit = PAGE_SIZE - ) - - LoadResult.Page( - data = messages, - prevKey = if (position > 0) position - PAGE_SIZE else null, - nextKey = if (messages.isEmpty()) null else position + PAGE_SIZE - ) - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState): Int? { - return state.anchorPosition - } -} diff --git a/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt b/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt deleted file mode 100644 index fdec53f..0000000 --- a/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt +++ /dev/null @@ -1,103 +0,0 @@ -package chats.data.local.paging - -import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType -import androidx.paging.PagingState -import androidx.paging.RemoteMediator -import androidx.room.withTransaction -import chats.data.local.dao.ChatDao -import chats.data.local.dao.MessageDao -import chats.data.local.database.AppDatabase -import chats.data.local.database.MessageEntity -import chats.data.local.mappers.toEntity -import chats.data.repository.toDomain as dtoToDomain -import chats.data.remote.api.ChatApi -import chats.domain.model.MessageStatus -import core.network.ServerConfig -import core.security.TokenManager - -/** - * RemoteMediator для синхронизации сообщений с сервером. - * Работает с Room через withTransaction для атомарности. - */ -@OptIn(ExperimentalPagingApi::class) -class MessageRemoteMediator( - private val chatId: String, - private val messageDao: MessageDao, - private val chatDao: ChatDao, - private val chatApi: ChatApi, - private val tokenManager: TokenManager, - private val appDatabase: AppDatabase, - private val serverConfig: ServerConfig -) : RemoteMediator() { - - companion object { - private const val PAGE_SIZE = 30 - } - - override suspend fun load( - loadType: LoadType, - state: PagingState - ): RemoteMediator.MediatorResult { - return try { - val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - - val cursor = when (loadType) { - LoadType.REFRESH -> { - // При refresh загружаем новые сообщения после последнего локального - messageDao.getLastMessage(chatId)?.serverId - } - LoadType.PREPEND -> { - // Загружаем более старые сообщения перед первым локальным - messageDao.getFirstMessage(chatId)?.serverId - } - LoadType.APPEND -> { - // Загружаем новые сообщения после последнего локального - messageDao.getLastMessage(chatId)?.serverId - } - } - - val remoteMessages = chatApi.getMessages(chatId, cursor = cursor, limit = PAGE_SIZE) - val endOfPaginationReached = remoteMessages.size < PAGE_SIZE - - appDatabase.withTransaction { - if (loadType == LoadType.REFRESH) { - // При полном обновлении можно очистить старые SENT/DELIVERED/READ, - // но оставить локальные PENDING/FAILED - // messageDao.deleteMessagesByChatId(chatId) // Опционально - } - - val entities = remoteMessages.map { dto -> - val existing = messageDao.getMessageByServerId(dto.id) - if (existing != null) { - // Обновляем существующее, сохраняя localId - existing.copy( - content = dto.content, - reactionsJson = com.google.gson.Gson().toJson( - dto.reactions?.associate { it.emoji to it.count } ?: emptyMap() - ), - status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED, - updatedAtMillis = System.currentTimeMillis() - ) - } else { - // Создаём новое - dto.dtoToDomain(currentUserId, baseUrl).toEntity(MessageStatus.SENT).copy( - localId = java.util.UUID.randomUUID().toString(), - serverId = dto.id, - idempotencyKey = dto.id - ) - } - } - - messageDao.insertMessages(entities) - } - - RemoteMediator.MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) - } catch (e: Exception) { - RemoteMediator.MediatorResult.Error(e) - } - } - - override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH -} diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt deleted file mode 100644 index 140b813..0000000 --- a/client-mobile/chats/data/remote/api/ChatApi.kt +++ /dev/null @@ -1,130 +0,0 @@ -package chats.data.remote.api - -import chats.data.remote.dto.ChatDto -import chats.data.remote.dto.MessageDto -import retrofit2.http.* - -data class SendMessageRequest( - val content: String?, - val type: String = "text", - val attachments: List? = null, - val replyToId: String? = null, - val quote: String? = null, - val forwardedFromId: String? = null -) - -data class AttachmentRequest( - val type: String, - val url: String, - val fileName: String, - val fileSize: Long -) - -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("pivot") pivot: Long? = null, - @Query("limit") limit: Int? = 50 - ): List - - @POST("messages/chat/{chatId}") - suspend fun sendMessage( - @Path("chatId") chatId: String, - @Body request: SendMessageRequest, - @Header("X-Idempotency-Key") idempotencyKey: String - ): String - - @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): KlipyResponse - - @GET("klipy/search") - suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse - - @GET("klipy/categories") - suspend fun getGifCategories(): GifCategoriesResponse - - @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, @Query("emoji") emoji: String) - - @POST("chats/favorites") - suspend fun getOrCreateFavorites(): ChatDto - - @POST("chats/personal") - suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto - - @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) - - @DELETE("messages/{messageId}") - suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response - - @PUT("messages/{messageId}") - suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): String -} - -data class CreatePersonalChatRequest( - val userId: String -) - -data class KlipyResponse( - val data: KlipyDataWrapper -) - -data class KlipyDataWrapper( - val data: List -) - -data class KlipyGifDto( - val id: String, - val images: GifImagesDto? = null, - val files: Map>? = null, - val file: Map>? = null, - val media_formats: Map? = null, - val title: String? = null -) - -data class GifImagesDto( - val fixed_height: GifImageSourceDto? = null, - val original: GifImageSourceDto? = null, - val fixed_height_small: GifImageSourceDto? = null -) - -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 -) - -data class GifCategoriesResponse( - val data: GifCategoriesData -) - -data class GifCategoriesData( - val categories: List -) diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt deleted file mode 100644 index 9dcb104..0000000 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ /dev/null @@ -1,57 +0,0 @@ -package chats.data.remote.dto - -import com.google.gson.annotations.SerializedName - -data class UserBasicDto( - @SerializedName("id") val id: String, - @SerializedName("username") val username: String? = null, - @SerializedName("displayName") val displayName: String? = null, - @SerializedName("avatarUrl") val avatarUrl: String? = null -) - -data class MessageDto( - @SerializedName("id", alternate = ["Id"]) val id: String, - @SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null, - @SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null, - @SerializedName("content", alternate = ["Content"]) val content: String? = null, - @SerializedName("type", alternate = ["Type"]) val type: String? = null, - @SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null, - @SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null, - @SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null, - @SerializedName("media", alternate = ["Media"]) val media: List = emptyList(), - @SerializedName("reactions", alternate = ["Reactions"]) val reactions: List? = emptyList(), - @SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null, - @SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false, - @SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null, - @SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null -) - -data class ReactionDto( - @SerializedName("emoji") val emoji: String, - @SerializedName("count") val count: Int, - @SerializedName("isSetByMe") val isSetByMe: Boolean -) - -data class MediaItemDto( - @SerializedName("id") val id: String, - @SerializedName("type") val type: String, - @SerializedName("url") val url: String, - @SerializedName("filename") val filename: String? = null, - @SerializedName("size") val size: Long? = null, - @SerializedName("duration") val duration: Double? = null -) - -data class ChatDto( - @SerializedName("id") val id: String, - @SerializedName("type") val type: String, - @SerializedName("name") val name: String? = null, - @SerializedName("avatar") val avatar: String? = null, - @SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0, - @SerializedName("messages") val messages: List = emptyList(), - @SerializedName("members") val members: List = emptyList() -) - -data class ChatMemberDto( - @SerializedName("userId") val userId: String, - @SerializedName("user") val user: UserBasicDto? = null -) diff --git a/client-mobile/chats/data/remote/signalr/ChatEvent.kt b/client-mobile/chats/data/remote/signalr/ChatEvent.kt deleted file mode 100644 index 5c82b99..0000000 --- a/client-mobile/chats/data/remote/signalr/ChatEvent.kt +++ /dev/null @@ -1,33 +0,0 @@ -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, val isRemoved: Boolean = false) : ChatEvent() - data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent() - data class MessageUnpinned(val chatId: String, val messageId: 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 deleted file mode 100644 index 3379e5f..0000000 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ /dev/null @@ -1,280 +0,0 @@ -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.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton - -import kotlinx.coroutines.delay - -data class ReadMessagesRequest( - val chatId: String, - val lastReadMessageId: String, - val lastReadSequenceId: Int -) - -data class MessagesReadEvent( - @com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null, - @com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null, - @com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null -) { - val effectiveChatId: String get() = chatId ?: "" - val effectiveUserId: String get() = userId ?: "" - val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0 -} - -data class ReactionEvent( - @com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null, - @com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null, - @com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null, - @com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null, - @com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null -) - -enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED } - -@Singleton -class ChatHubClient @Inject constructor() { - private var hubConnection: HubConnection? = null - private val _events = MutableSharedFlow(extraBufferCapacity = 1024) - val events: SharedFlow = _events.asSharedFlow() - - private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED) - val status: StateFlow = _status.asStateFlow() - - private val scope = CoroutineScope(Dispatchers.IO) - private var lastBaseUrl: String? = null - private var lastToken: String? = null - - fun connect(baseUrl: String, accessToken: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return - - lastBaseUrl = baseUrl - lastToken = accessToken - _status.value = ConnectionStatus.CONNECTING - - hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat") - .withAccessTokenProvider(Single.just(accessToken)) - .build() - - setupHandlers() - - hubConnection?.onClosed { exception -> - Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception) - _status.value = ConnectionStatus.DISCONNECTED - scope.launch { - delay(5000) - connect(baseUrl, accessToken) - } - } - - scope.launch { - try { - hubConnection?.start()?.blockingAwait() - _status.value = ConnectionStatus.CONNECTED - Log.d("ChatHubClient", "SignalR Connected") - } catch (e: Exception) { - Log.e("ChatHubClient", "SignalR Connection Error", e) - _status.value = ConnectionStatus.DISCONNECTED - } - } - } - - private fun setupHandlers() { - hubConnection?.let { conn -> - conn.on("new_message", { message: MessageDto -> - _events.tryEmit(ChatEvent.NewMessage(message)) - }, MessageDto::class.java) - - conn.on("message_edited", { messageId: String, chatId: String, content: String -> - _events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content)) - }, String::class.java, String::class.java, String::class.java) - - conn.on("message_deleted", { messageId: String, chatId: String -> - _events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId)) - }, String::class.java, String::class.java) - - conn.on("messages_read", { data: MessagesReadEvent -> - _events.tryEmit(ChatEvent.MessagesRead( - data.effectiveChatId, - data.effectiveUserId, - data.effectiveLastReadSequenceId - )) - }, MessagesReadEvent::class.java) - - conn.on("user_typing", { data: ReactionEvent -> - _events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: "")) - }, ReactionEvent::class.java) - - conn.on("user_stopped_typing", { data: ReactionEvent -> - _events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: "")) - }, ReactionEvent::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_added", { data: ReactionEvent -> - _events.tryEmit(ChatEvent.ReactionUpdated( - data.messageId ?: "", - data.chatId ?: "", - data.userId ?: "", - data.emoji ?: "", - isRemoved = false - )) - }, ReactionEvent::class.java) - - conn.on("reaction_removed", { data: ReactionEvent -> - _events.tryEmit(ChatEvent.ReactionUpdated( - data.messageId ?: "", - data.chatId ?: "", - data.userId ?: "", - data.emoji ?: "", - isRemoved = true - )) - }, ReactionEvent::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) - conn.on("message_pinned", { data: Map -> - // The web version expects a message object, but here we might get a partial DTO or just IDs. - // Let's assume we get { chatId, message: MessageDto } based on web - // We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps. - // For simplicity, we might needs a dedicated DTO if it fails. - }, Map::class.java) - - conn.on("message_pinned", { chatId: String, message: MessageDto -> - _events.tryEmit(ChatEvent.MessagePinned(chatId, message)) - }, String::class.java, MessageDto::class.java) - - conn.on("message_unpinned", { chatId: String, messageId: String -> - _events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId)) - }, String::class.java, String::class.java) - } - } - - fun disconnect() { - hubConnection?.stop() - _status.value = ConnectionStatus.DISCONNECTED - } - - fun addReaction(messageId: String, chatId: String, emoji: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("add_reaction", mapOf( - "messageId" to messageId, - "chatId" to chatId, - "emoji" to emoji - ))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) } - ?.subscribe() - Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId") - } else { - Log.w("ChatHubClient", "Cannot add_reaction: Not connected") - } - } - - fun removeReaction(messageId: String, chatId: String, emoji: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("remove_reaction", mapOf( - "messageId" to messageId, - "chatId" to chatId, - "emoji" to emoji - ))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) } - ?.subscribe() - Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId") - } - } - - fun pinMessage(messageId: String, chatId: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("pin_message", mapOf( - "messageId" to messageId, - "chatId" to chatId - ))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) } - ?.subscribe() - } - } - - fun unpinMessage(messageId: String, chatId: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("unpin_message", mapOf( - "messageId" to messageId, - "chatId" to chatId - ))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) } - ?.subscribe() - } - } - - fun readMessages(request: ReadMessagesRequest) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("read_messages", request) - ?.doOnError { Log.e("ChatHubClient", "read_messages error", it) } - ?.subscribe() - Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}") - } - } - - fun joinChat(chatId: String) { - scope.launch { - // Wait for connection to be established if it's currently connecting - var attempts = 0 - while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) { - delay(500) - attempts++ - } - - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("join_chat", chatId) - ?.doOnError { Log.e("ChatHubClient", "join_chat error", it) } - ?.subscribe() - Log.d("ChatHubClient", "Joined chat room: $chatId") - } else { - Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected") - } - } - } - - fun sendTypingIndicator(chatId: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("typing_start", chatId) - ?.doOnError { Log.e("ChatHubClient", "typing_start error", it) } - ?.subscribe() - Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId") - } - } - - fun sendUserStoppedTyping(chatId: String) { - if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("typing_stop", chatId) - ?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) } - ?.subscribe() - Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId") - } - } -} diff --git a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt deleted file mode 100644 index 4cc2db8..0000000 --- a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt +++ /dev/null @@ -1,94 +0,0 @@ -package chats.data.remote.signalr - -import android.content.Context -import core.notifications.data.ActiveChatTracker -import core.notifications.data.NotificationHelper -import core.security.TokenManager -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SignalRNotificationObserver @Inject constructor( - private val signalrClient: ChatHubClient, - private val activeChatTracker: ActiveChatTracker, - private val tokenManager: TokenManager, - private val chatRepository: chats.domain.repository.ChatRepository, - @ApplicationContext private val context: Context -) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var isStarted = false - private val processedMessageIds = mutableSetOf() - - fun refresh() { - scope.launch { - try { - val chats = chatRepository.getChats() - val total = chats.sumOf { it.unreadCount } - activeChatTracker.setTotalUnreadCount(total) - } catch (e: Exception) { - // Ignore load error - } - } - } - - fun start() { - if (isStarted) return - isStarted = true - - // Initial count load - refresh() - - signalrClient.events - .onEach { event -> - when (event) { - is ChatEvent.NewMessage -> { - val currentUserId = tokenManager.getUserId() - val message = event.message - - // Don't show if it's our message or already processed - if (message.senderId == currentUserId) return@onEach - if (processedMessageIds.contains(message.id)) return@onEach - - // Mark as processed - processedMessageIds.add(message.id) - if (processedMessageIds.size > 200) { - processedMessageIds.remove(processedMessageIds.first()) - } - - // Increment immediately for UI feedback - activeChatTracker.incrementUnreadCount() - - // Refresh total count from source of truth in background - refresh() - - // Don't show if this chat is currently open - if (activeChatTracker.currentChatId.value == message.chatId) return@onEach - - NotificationHelper.showNotification( - context = context, - title = message.sender?.displayName ?: "Новое сообщение", - body = message.content ?: "Вам прислали вложение", - type = "chat", - chatId = message.chatId, - notificationId = message.id.hashCode(), - totalCount = activeChatTracker.totalUnreadCount.value - ) - } - is ChatEvent.MessagesRead -> { - // If anyone read messages, sync our total count - refresh() - } - else -> Unit - } - } - .launchIn(scope) - } -} diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt deleted file mode 100644 index 3612299..0000000 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ /dev/null @@ -1,416 +0,0 @@ -package chats.data.repository - -import android.util.Log -import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.map -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.WorkManager -import chats.data.local.dao.ChatDao -import chats.data.local.dao.MessageDao -import chats.data.local.database.MessageEntity -import chats.data.local.mappers.createPendingMessageEntity -import chats.data.local.mappers.toDomain -import chats.data.local.mappers.toEntity -import chats.data.local.paging.MessagePagingSource -import chats.data.local.paging.MessageRemoteMediator -import chats.data.remote.api.ChatApi -import chats.data.remote.api.SendMessageRequest -import chats.data.remote.dto.MessageDto -import chats.data.workers.ChatSyncWorker -import chats.data.workers.SendMessageWorker -import chats.domain.model.Chat -import chats.domain.model.Message -import chats.domain.model.MessageStatus -import chats.domain.repository.ChatRepository -import core.network.ServerConfig -import core.security.TokenManager -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.asRequestBody -import javax.inject.Inject - -/** - * Реализация ChatRepository с поддержкой офлайн-режима. - * Использует паттерн Single Source of Truth: UI всегда берет данные из Room. - */ -class ChatRepositoryImpl @Inject constructor( - private val api: ChatApi, - private val tokenManager: TokenManager, - private val serverConfig: ServerConfig, - private val messageDao: MessageDao, - private val chatDao: ChatDao, - private val workManager: WorkManager, - private val appDatabase: chats.data.local.database.AppDatabase, - private val hubClient: chats.data.remote.signalr.ChatHubClient -) : ChatRepository { - - private val gson = com.google.gson.Gson() - - // ==================== Чаты ==================== - - override fun getChatsFlow(): Flow> { - // Single Source of Truth - данные из Room - return chatDao.getAllChats().map { entities -> - entities.map { it.toDomain() } - } - } - - override suspend fun getChats(): List { - val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val gson = com.google.gson.Gson() - - return try { - // Пробуем получить с сервера - val remoteChats = api.getChats() - val domainChats = remoteChats.map { it.toDomain(currentUserId, baseUrl) } - - // Сохраняем в локальную БД - val entities = domainChats.map { chat -> - val existing = chatDao.getChatByRemoteId(chat.id) - chat.toEntity().copy( - localId = existing?.localId ?: java.util.UUID.randomUUID().toString() - ) - } - chatDao.insertChats(entities) - - domainChats - } catch (e: Exception) { - Log.w("ChatRepo", "Failed to fetch chats from server, returning local", e) - // При ошибке возвращаем локальные данные - emptyList() - } - } - - override suspend fun syncChats() { - try { - val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val remoteChats = api.getChats() - val gson = com.google.gson.Gson() - - val entities = remoteChats.map { dto -> - val existing = chatDao.getChatByRemoteId(dto.id) - val lastMessage = dto.messages.firstOrNull() - val timestamp = lastMessage?.createdAt?.let { - try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } - catch (e: Exception) { null } - } - val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl) - dto.toDomain(currentUserId, baseUrl).toEntity().copy( - localId = existing?.localId ?: java.util.UUID.randomUUID().toString(), - lastMessageText = lastMessage?.content, - lastMessageTimestamp = timestamp, - lastMessageJson = lastMessageDomain?.let { gson.toJson(it) } - ) - } - chatDao.insertChats(entities) - } catch (e: Exception) { - Log.e("ChatRepo", "Sync chats failed", e) - } - } - - // ==================== Сообщения ==================== - - override fun getMessagesFlow(chatId: String): Flow> { - // Single Source of Truth - всегда из Room - return messageDao.getMessagesByChatId(chatId).map { entities -> - entities.map { it.toDomain() } - } - } - - /** - * Получает сообщения с помощью Paging 3 и RemoteMediator - */ - @OptIn(ExperimentalPagingApi::class) - override fun getMessagesPagingSource(chatId: String): Flow> { - return Pager( - config = PagingConfig( - pageSize = 30, - prefetchDistance = 10, - enablePlaceholders = false, - initialLoadSize = 60 - ), - pagingSourceFactory = { - MessagePagingSource(chatId, messageDao) - }, - remoteMediator = MessageRemoteMediator( - chatId = chatId, - messageDao = messageDao, - chatDao = chatDao, - chatApi = api, - tokenManager = tokenManager, - appDatabase = appDatabase, - serverConfig = serverConfig - ) - ).flow.map { pagingData -> - pagingData.map { entity -> entity.toDomain() } - } - } - - override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val currentUserId = tokenManager.getUserId() ?: "" - - return try { - Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit") - val messages = api.getMessages(chatId, cursor = cursor, limit = limit) - - if (messages.isNotEmpty()) { - Log.d("ChatRepo", "Received ${messages.size} messages") - // Сохраняем в локальную БД - saveMessagesToLocal(messages, chatId, currentUserId) - } - - messages.map { msg -> - msg.toDomain(currentUserId, baseUrl).copy(isRead = true) - } - } catch (e: Exception) { - Log.e("ChatRepo", "Fetch messages failed, returning local", e) - emptyList() - } - } - - override suspend fun sendMessage( - chatId: String, - content: String?, - type: String, - attachments: List?, - replyToId: String?, - forwardedFromId: String? - ): Message { - val userId = tokenManager.getUserId() ?: "" - val userName = tokenManager.getUsername() ?: userId - - // 1. Создаем локальное сообщение со статусом PENDING - val mediaType = when (type) { - "image" -> "IMAGE" - "video" -> "VIDEO" - "audio", "voice" -> "AUDIO" - else -> "TEXT" - } - - val mediaJson = attachments?.map { - chats.domain.model.Media( - id = java.util.UUID.randomUUID().toString(), - type = it.type, - url = it.url, - filename = it.fileName, - size = it.fileSize - ) - }?.let { gson.toJson(it) } ?: "[]" - - val pendingEntity = createPendingMessageEntity( - chatId = chatId, - senderId = userId, - senderName = userName, - content = content, - mediaType = mediaType, - mediaJson = mediaJson, - replyToId = replyToId - ) - - // 2. Сохраняем в Room - messageDao.insertMessage(pendingEntity) - - // 3. Ставим задачу в WorkManager для отправки - val workRequest = SendMessageWorker.createWorkRequest(pendingEntity.localId) - workManager.enqueueUniqueWork( - "send_${pendingEntity.localId}", - ExistingWorkPolicy.REPLACE, - workRequest - ) - - Log.d("ChatRepo", "Queued message for sending: ${pendingEntity.localId}") - - // 4. Возвращаем доменную модель для немедленного отображения в UI - return pendingEntity.toDomain() - } - - override suspend fun retryFailedMessage(localId: String) { - val message = messageDao.getMessageByLocalId(localId) - ?: return - - if (message.status != MessageStatus.FAILED) return - - // Сбрасываем статус и ставим в очередь - messageDao.updateMessageStatus( - localId = localId, - status = MessageStatus.PENDING, - updatedAtMillis = System.currentTimeMillis() - ) - - val workRequest = SendMessageWorker.createWorkRequest(localId) - workManager.enqueueUniqueWork( - "send_$localId", - ExistingWorkPolicy.REPLACE, - workRequest - ) - } - - override suspend fun deleteLocalMessage(messageId: String) { - messageDao.deleteMessageByLocalId(messageId) - } - - override suspend fun saveMessage(message: Message) { - // Сохраняем входящее сообщение из SignalR - val entity = message.toEntity(MessageStatus.DELIVERED) - messageDao.insertMessage(entity) - } - - // ==================== Синхронизация ==================== - - override suspend fun syncMessagesForChat(chatId: String) { - val workRequest = ChatSyncWorker.createOneTimeWorkRequest(chatId) - workManager.enqueue(workRequest) - } - - override suspend fun schedulePeriodicSync() { - val workRequest = ChatSyncWorker.createPeriodicWorkRequest() - workManager.enqueueUniquePeriodicWork( - "periodic_chat_sync", - ExistingPeriodicWorkPolicy.KEEP, - workRequest - ) - } - - // ==================== Вспомогательные методы ==================== - - private suspend fun saveMessagesToLocal(messages: List, chatId: String, currentUserId: String) { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - messages.forEach { dto -> - val existing = messageDao.getMessageByServerId(dto.id) - if (existing == null) { - val entity = MessageEntity( - localId = java.util.UUID.randomUUID().toString(), - serverId = dto.id, - idempotencyKey = dto.id, - chatId = chatId, - senderId = dto.senderId ?: dto.sender?.id ?: currentUserId, - senderName = dto.sender?.displayName ?: dto.sender?.username ?: "", - senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" }, - content = dto.content, - sequenceId = dto.sequenceId?.toLong() ?: 0L, - createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(), - mediaType = dto.type ?: "TEXT", - mediaJson = gson.toJson(dto.media.map { mediaItem -> - chats.domain.model.Media( - id = mediaItem.id, - type = mediaItem.type, - url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" }, - filename = mediaItem.filename, - size = mediaItem.size, - duration = mediaItem.duration - ) - }), - reactionsJson = gson.toJson( - dto.reactions?.associate { it.emoji to it.count } ?: emptyMap() - ), - status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED, - createdAtMillis = System.currentTimeMillis(), - updatedAtMillis = System.currentTimeMillis() - ) - messageDao.insertMessage(entity) - } - } - } - - // ==================== Остальные методы ==================== - - override suspend fun addReaction(messageId: String, emoji: String) { - try { - api.addReaction(messageId, emoji) - } catch (e: Exception) { - Log.e("ChatRepo", "Add reaction failed", e) - throw e - } - } - - override suspend fun sendTypingStatus(chatId: String) { - try { - api.sendTypingStatus(chatId) - } catch (e: Exception) { - // Игнорируем ошибки typing status - } - } - - override suspend fun resetUnreadCount(chatId: String) { - chatDao.updateUnreadCount(chatId, 0) - } - - override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) { - try { - hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId)) - } catch (e: Exception) { - Log.e("ChatRepo", "Error marking messages as read", e) - } - } - - override suspend fun uploadMedia(file: java.io.File): String { - val mimeType = when (file.extension.lowercase()) { - "jpg", "jpeg" -> "image/jpeg" - "png" -> "image/png" - "webp" -> "image/webp" - "mp4" -> "video/mp4" - "mp3", "m4a", "wav" -> "audio/mpeg" - else -> "application/octet-stream" - } - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - val body = MultipartBody.Part.createFormData("file", file.name, requestFile) - return api.uploadFile(body).url - } - - override suspend fun getTrendingGifs(page: Int): List { - return api.getTrendingGifs(page).data.data - } - - override suspend fun searchGifs(query: String, page: Int): List { - return api.searchGifs(query, page).data.data - } - - override suspend fun getGifCategories(): List { - return api.getGifCategories().data.categories - } - - override suspend fun getOrCreateFavorites(): Chat { - val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val dto = api.getOrCreateFavorites() - val chat = dto.toDomain(currentUserId, baseUrl) - chatDao.insertChat(chat.toEntity()) - return chat - } - - override suspend fun createPersonalChat(userId: String): Chat { - val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val request = chats.data.remote.api.CreatePersonalChatRequest(userId) - return api.createPersonalChat(request).toDomain(currentUserId, baseUrl) - } - - override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) { - api.deleteMessage(messageId, forEveryone) - } - - override suspend fun editMessage(messageId: String, content: String): Message { - val request = SendMessageRequest(content = content) - val currentUserId = tokenManager.getUserId() ?: "" - val returnedId = api.editMessage(messageId, request) - - return Message( - id = returnedId, - chatId = "", - senderId = currentUserId, - senderName = "", - content = content, - sequenceId = 0, - createdAt = java.time.ZonedDateTime.now().toString() - ) - } -} diff --git a/client-mobile/chats/data/repository/Mappers.kt b/client-mobile/chats/data/repository/Mappers.kt deleted file mode 100644 index e3ee08e..0000000 --- a/client-mobile/chats/data/repository/Mappers.kt +++ /dev/null @@ -1,83 +0,0 @@ -package chats.data.repository - -import chats.data.remote.dto.* -import chats.domain.model.* - -// Mappers -fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat { - val chatName = name ?: if (type == "personal") { - members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat" - } else "Group Chat" - - val chatAvatar = (avatar ?: if (type == "personal") { - members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl - } else null)?.ensureAbsoluteUrl(baseUrl) - - return Chat( - id = id, - type = type, - name = chatName, - avatar = chatAvatar, - unreadCount = unreadCount, - lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl) - ) -} - -fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message { - val firstMedia = media.firstOrNull() - val isVoiceFromFilename = firstMedia?.filename?.endsWith(".mp3", ignoreCase = true) == true || - firstMedia?.filename?.startsWith("voice_", ignoreCase = true) == true - - val domainMediaType = when (type) { - "gif" -> MediaType.GIF - "image", "photo" -> MediaType.IMAGE - "video" -> MediaType.VIDEO - "audio", "voice" -> MediaType.AUDIO - "file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE - else -> when (firstMedia?.type) { - "image", "photo" -> MediaType.IMAGE - "video" -> MediaType.VIDEO - "audio", "voice" -> MediaType.AUDIO - "file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE - else -> MediaType.TEXT - } - } - - return Message( - id = id, - chatId = chatId ?: "", - senderId = senderId ?: "", - senderName = sender?.displayName ?: "Unknown", - senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl), - content = content, - sequenceId = sequenceId ?: 0, - createdAt = createdAt ?: "", - media = media.map { - chats.domain.model.Media( - id = it.id ?: java.util.UUID.randomUUID().toString(), - type = if (isVoiceFromFilename) "voice" else (it.type ?: "unknown"), - url = (it.url ?: "").ensureAbsoluteUrl(baseUrl), - filename = it.filename, - size = it.size, - duration = it.duration - ) - }, - mediaType = domainMediaType, - reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(), - isRead = senderId == currentUserId, - isPinned = isPinned ?: false, - isForwarded = forwardedFromId != null, - forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username, - replyTo = replyTo?.toDomain(currentUserId, baseUrl) - ) -} - -fun String.ensureAbsoluteUrl(baseUrl: String): String { - return if (this.startsWith("http")) { - this - } else { - val base = baseUrl.removeSuffix("/") - val path = if (this.startsWith("/")) this else "/$this" - "$base$path" - } -} diff --git a/client-mobile/chats/data/workers/ChatSyncWorker.kt b/client-mobile/chats/data/workers/ChatSyncWorker.kt deleted file mode 100644 index 035f842..0000000 --- a/client-mobile/chats/data/workers/ChatSyncWorker.kt +++ /dev/null @@ -1,227 +0,0 @@ -package chats.data.workers - -import android.content.Context -import android.util.Log -import androidx.hilt.work.HiltWorker -import androidx.work.* -import chats.data.local.dao.ChatDao -import chats.data.local.dao.MessageDao -import chats.data.remote.dto.* -import chats.data.repository.toDomain -import chats.domain.model.MessageStatus -import core.security.TokenManager -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.IOException -import java.net.SocketTimeoutException -import java.util.concurrent.TimeUnit - -/** - * Worker для периодической синхронизации чатов и сообщений. - * Запускается при наличии сети для получения новых сообщений с сервера. - */ -@HiltWorker -class ChatSyncWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted params: WorkerParameters, - private val chatDao: ChatDao, - private val messageDao: MessageDao, - private val chatApi: chats.data.remote.api.ChatApi, - private val tokenManager: TokenManager, - private val serverConfig: core.network.ServerConfig -) : CoroutineWorker(context, params) { - - companion object { - const val WORK_TAG = "chat_sync_worker" - const val KEY_CHAT_ID = "chat_id" - const val KEY_CURSOR = "cursor" - - /** - * Создает периодический запрос на синхронизацию всех чатов - */ - fun createPeriodicWorkRequest(): PeriodicWorkRequest { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - return PeriodicWorkRequestBuilder( - repeatInterval = 15, - repeatIntervalTimeUnit = TimeUnit.MINUTES - ) - .setConstraints(constraints) - .addTag(WORK_TAG) - .build() - } - - /** - * Создает разовый запрос на синхронизацию конкретного чата - */ - fun createOneTimeWorkRequest(chatId: String? = null, cursor: String? = null): OneTimeWorkRequest { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val inputData = workDataOf( - KEY_CHAT_ID to chatId, - KEY_CURSOR to cursor - ) - - return OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .setInputData(inputData) - .addTag(WORK_TAG) - .build() - } - } - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - Log.d("ChatSyncWorker", "Starting chat sync") - - try { - // 1. Синхронизируем список чатов - syncChats() - - // 2. Если указан chatId - синхронизируем сообщения - val chatId = inputData.getString(KEY_CHAT_ID) - if (chatId != null) { - syncMessagesForChat(chatId) - } - - Log.i("ChatSyncWorker", "Sync completed successfully") - Result.success() - - } catch (e: IOException) { - Log.w("ChatSyncWorker", "Network error during sync: ${e.message}") - Result.retry() - } catch (e: SocketTimeoutException) { - Log.w("ChatSyncWorker", "Timeout during sync: ${e.message}") - Result.retry() - } catch (e: Exception) { - Log.e("ChatSyncWorker", "Error during sync", e) - Result.failure() - } - } - - private suspend fun syncChats() { - val remoteChats = chatApi.getChats() - val localChats = mutableListOf() - val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val gson = com.google.gson.Gson() - - remoteChats.forEach { dto -> - val existingChat = chatDao.getChatByRemoteId(dto.id) - val lastMessage = dto.messages.firstOrNull() - val timestamp = lastMessage?.createdAt?.let { - try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } - catch (e: Exception) { null } - } - val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl) - val chatEntity = chats.data.local.database.ChatEntity( - localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(), - remoteId = dto.id, - type = dto.type, - name = dto.name ?: "", - avatar = dto.avatar, - unreadCount = dto.unreadCount, - lastMessageText = lastMessage?.content, - lastMessageTimestamp = timestamp, - lastMessageJson = lastMessageDomain?.let { gson.toJson(it) } - ) - localChats.add(chatEntity) - } - - // Если сервер не вернул Избранное — создаём/получаем его отдельно - val hasFavorites = remoteChats.any { it.type == "favorites" } - if (!hasFavorites) { - try { - val favDto = chatApi.getOrCreateFavorites() - val existingChat = chatDao.getChatByRemoteId(favDto.id) - val favTimestamp = favDto.messages.firstOrNull()?.createdAt?.let { - try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } catch (e: Exception) { null } - } - val favLastMessageDomain = favDto.messages.firstOrNull()?.toDomain(currentUserId, baseUrl) - val favEntity = chats.data.local.database.ChatEntity( - localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(), - remoteId = favDto.id, - type = favDto.type, - name = favDto.name ?: "Избранное", - avatar = favDto.avatar, - unreadCount = favDto.unreadCount, - lastMessageText = favDto.messages.firstOrNull()?.content, - lastMessageTimestamp = favTimestamp, - lastMessageJson = favLastMessageDomain?.let { gson.toJson(it) } - ) - localChats.add(favEntity) - Log.d("ChatSyncWorker", "Created favorites chat: ${favDto.id}") - } catch (e: Exception) { - Log.e("ChatSyncWorker", "Failed to get or create favorites", e) - } - } - - chatDao.insertChats(localChats) - Log.d("ChatSyncWorker", "Synced ${localChats.size} chats") - } - - private suspend fun syncMessagesForChat(chatId: String) { - val lastMessage = messageDao.getMessagesByStatus( - chatId = chatId, - statuses = listOf(MessageStatus.SENT, MessageStatus.DELIVERED, MessageStatus.READ) - ).maxByOrNull { it.sequenceId } - - val cursor = lastMessage?.serverId - val messages = chatApi.getMessages(chatId, cursor = cursor, limit = 50) - - if (messages.isNotEmpty()) { - saveMessagesToLocal(messages, chatId) - Log.d("ChatSyncWorker", "Synced ${messages.size} messages for chat $chatId") - } - } - - private suspend fun saveMessagesToLocal( - messages: List, - chatId: String - ) { - val currentUserId = tokenManager.getUserId() ?: "" - val gson = com.google.gson.Gson() - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - - messages.forEach { dto -> - val existing = messageDao.getMessageByServerId(dto.id) - if (existing == null) { - val entity = chats.data.local.database.MessageEntity( - localId = java.util.UUID.randomUUID().toString(), - serverId = dto.id, - idempotencyKey = dto.id, - chatId = chatId, - senderId = dto.senderId ?: dto.sender?.id ?: currentUserId, - senderName = dto.sender?.displayName ?: dto.sender?.username ?: "", - senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" }, - content = dto.content, - sequenceId = dto.sequenceId?.toLong() ?: 0L, - createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(), - mediaType = dto.type ?: "TEXT", - mediaJson = gson.toJson(dto.media.map { mediaItem -> - chats.domain.model.Media( - id = mediaItem.id, - type = mediaItem.type, - url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" }, - filename = mediaItem.filename, - size = mediaItem.size, - duration = mediaItem.duration - ) - }), - reactionsJson = gson.toJson( - dto.reactions?.associate { it.emoji to it.count } ?: emptyMap() - ), - status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED, - createdAtMillis = System.currentTimeMillis(), - updatedAtMillis = System.currentTimeMillis() - ) - messageDao.insertMessage(entity) - } - } - } -} diff --git a/client-mobile/chats/data/workers/SendMessageWorker.kt b/client-mobile/chats/data/workers/SendMessageWorker.kt deleted file mode 100644 index 99dc541..0000000 --- a/client-mobile/chats/data/workers/SendMessageWorker.kt +++ /dev/null @@ -1,170 +0,0 @@ -package chats.data.workers - -import android.content.Context -import android.util.Log -import androidx.hilt.work.HiltWorker -import androidx.work.* -import chats.data.local.dao.MessageDao -import chats.data.local.database.MessageEntity -import chats.data.remote.api.AttachmentRequest -import chats.data.remote.api.ChatApi -import chats.data.remote.api.SendMessageRequest -import chats.domain.model.MessageStatus -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.IOException -import java.net.SocketTimeoutException -import java.util.concurrent.TimeUnit - -/** - * Worker для отправки сообщений с поддержкой офлайн-режима. - * Использует WorkManager для гарантированной доставки при появлении сети. - */ -@HiltWorker -class SendMessageWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted params: WorkerParameters, - private val messageDao: MessageDao, - private val chatApi: ChatApi -) : CoroutineWorker(context, params) { - - companion object { - const val WORK_TAG = "send_message_worker" - const val KEY_LOCAL_ID = "local_id" - const val MAX_RETRY_COUNT = 5 - - /** - * Создает запрос на отправку сообщения через WorkManager - */ - fun createWorkRequest(localId: String): OneTimeWorkRequest { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val inputData = workDataOf(KEY_LOCAL_ID to localId) - - return OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .setInputData(inputData) - .addTag(WORK_TAG) - .addTag("$WORK_TAG:$localId") - .setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, - WorkRequest.MIN_BACKOFF_MILLIS, - TimeUnit.MILLISECONDS - ) - .build() - } - } - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val localId = inputData.getString(KEY_LOCAL_ID) - ?: return@withContext Result.failure() - - Log.d("SendMessageWorker", "Starting work for message: $localId") - - try { - // Получаем сообщение из локальной БД - val message = messageDao.getMessageByLocalId(localId) - ?: return@withContext Result.failure( - workDataOf("error" to "Message not found: $localId") - ) - - // Проверяем, не отправлено ли уже - if (message.status == MessageStatus.SENT || message.status == MessageStatus.DELIVERED || message.status == MessageStatus.READ) { - Log.d("SendMessageWorker", "Message already sent: $localId") - return@withContext Result.success() - } - - // Проверяем лимит повторных попыток - if (message.retryCount >= MAX_RETRY_COUNT) { - Log.w("SendMessageWorker", "Max retries reached for message: $localId") - messageDao.updateMessageError( - localId = localId, - status = MessageStatus.FAILED, - errorMessage = "Max retry count exceeded", - updatedAtMillis = System.currentTimeMillis() - ) - return@withContext Result.failure( - workDataOf("error" to "Max retries reached") - ) - } - - // Обновляем статус на SENDING - messageDao.updateMessageStatus( - localId = localId, - status = MessageStatus.SENDING, - updatedAtMillis = System.currentTimeMillis() - ) - - // Выполняем запрос к API с Idempotency-Key - // Десериализуем media из JSON - val mediaList = try { - val gson = com.google.gson.Gson() - val type = object : com.google.gson.reflect.TypeToken>() {}.type - gson.fromJson>(message.mediaJson, type) ?: emptyList() - } catch (e: Exception) { - emptyList() - } - - val attachments = mediaList.map { - AttachmentRequest( - type = it.type, - url = it.url, - fileName = it.filename ?: "", - fileSize = it.size ?: 0L - ) - }.takeIf { it.isNotEmpty() } - - val request = SendMessageRequest( - content = message.content, - type = message.mediaType.lowercase(), - attachments = attachments, - replyToId = message.replyToServerId - ) - - val serverId = chatApi.sendMessage( - chatId = message.chatId, - request = request, - idempotencyKey = message.idempotencyKey - ) - - // Успешная отправка - обновляем статус и serverId - messageDao.updateMessageWithServerId( - localId = localId, - serverId = serverId, - status = MessageStatus.SENT, - updatedAtMillis = System.currentTimeMillis() - ) - - Log.i("SendMessageWorker", "Message sent successfully: local=$localId, server=$serverId") - Result.success() - - } catch (e: IOException) { - // Сетевая ошибка - повторяем позже - Log.w("SendMessageWorker", "Network error for message $localId: ${e.message}") - Result.retry() - } catch (e: SocketTimeoutException) { - Log.w("SendMessageWorker", "Timeout for message $localId: ${e.message}") - Result.retry() - } catch (e: Exception) { - // Остальные ошибки - помечаем как FAILED - Log.e("SendMessageWorker", "Error sending message $localId", e) - try { - messageDao.updateMessageError( - localId = localId, - status = MessageStatus.FAILED, - errorMessage = e.message ?: "Unknown error", - updatedAtMillis = System.currentTimeMillis() - ) - } catch (dbError: Exception) { - Log.e("SendMessageWorker", "Failed to update error status", dbError) - } - Result.failure( - workDataOf("error" to (e.message ?: "Unknown error")) - ) - } - } -} diff --git a/client-mobile/chats/di/ChatModule.kt b/client-mobile/chats/di/ChatModule.kt deleted file mode 100644 index 302bfe3..0000000 --- a/client-mobile/chats/di/ChatModule.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 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 ChatModule { - - @Provides - @Singleton - fun provideChatApi(retrofit: Retrofit): ChatApi { - return retrofit.create(ChatApi::class.java) - } - - @Provides - @Singleton - fun provideChatRepository( - impl: ChatRepositoryImpl - ): ChatRepository = impl - - @Provides - @Singleton - fun provideChatHubClient(): ChatHubClient { - return ChatHubClient() - } -} diff --git a/client-mobile/chats/di/LocalDatabaseModule.kt b/client-mobile/chats/di/LocalDatabaseModule.kt deleted file mode 100644 index 830aa0c..0000000 --- a/client-mobile/chats/di/LocalDatabaseModule.kt +++ /dev/null @@ -1,50 +0,0 @@ -package chats.di - -import android.content.Context -import androidx.room.Room -import androidx.work.WorkManager -import chats.data.local.dao.ChatDao -import chats.data.local.dao.MessageDao -import chats.data.local.dao.UserProfileDao -import chats.data.local.database.AppDatabase -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 - -/** - * Hilt модуль для предоставления зависимостей локальной базы данных и WorkManager - */ -@Module -@InstallIn(SingletonComponent::class) -object LocalDatabaseModule { - - @Provides - @Singleton - fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { - return Room.databaseBuilder( - context, - AppDatabase::class.java, - AppDatabase.DATABASE_NAME - ) - .fallbackToDestructiveMigration() - .build() - } - - @Provides - fun provideMessageDao(database: AppDatabase): MessageDao = database.messageDao() - - @Provides - fun provideChatDao(database: AppDatabase): ChatDao = database.chatDao() - - @Provides - fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao() - - @Provides - @Singleton - fun provideWorkManager(@ApplicationContext context: Context): WorkManager { - return WorkManager.getInstance(context) - } -} diff --git a/client-mobile/chats/domain/model/ChatModels.kt b/client-mobile/chats/domain/model/ChatModels.kt deleted file mode 100644 index 34d56c7..0000000 --- a/client-mobile/chats/domain/model/ChatModels.kt +++ /dev/null @@ -1,13 +0,0 @@ -package chats.domain.model - -import androidx.compose.runtime.Immutable - -@Immutable -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 deleted file mode 100644 index 9fc2e3d..0000000 --- a/client-mobile/chats/domain/model/Message.kt +++ /dev/null @@ -1,40 +0,0 @@ -package chats.domain.model - -import androidx.compose.runtime.Immutable - -import chats.domain.model.MessageStatus - -@Immutable -data class Message( - val id: String, - val chatId: String, - val senderId: String, - val senderName: String, - val senderAvatar: String? = null, - val content: String?, - val sequenceId: Int, - val createdAt: String, - val media: List = emptyList(), - val mediaType: MediaType = MediaType.TEXT, - val reactions: Map = emptyMap(), - val isRead: Boolean = false, - val isPinned: Boolean = false, - val isForwarded: Boolean = false, - val forwardedFromName: String? = null, - val replyTo: Message? = null, - val status: MessageStatus = MessageStatus.SENT // Новое поле для офлайн-статуса -) - -@Immutable -data class Media( - val id: String, - val type: String, - val url: String, - val filename: String? = null, - val size: Long? = null, - val duration: Double? = null -) - -enum class MediaType { - TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF -} diff --git a/client-mobile/chats/domain/model/MessageStatus.kt b/client-mobile/chats/domain/model/MessageStatus.kt deleted file mode 100644 index f446433..0000000 --- a/client-mobile/chats/domain/model/MessageStatus.kt +++ /dev/null @@ -1,30 +0,0 @@ -package chats.domain.model - -/** - * Статусы сообщения для отображения в UI и синхронизации - */ -enum class MessageStatus { - /** Сообщение создано локально, ожидает отправки */ - PENDING, - - /** Начата отправка на сервер */ - SENDING, - - /** Сообщение успешно отправлено на сервер */ - SENT, - - /** Сообщение доставлено получателю */ - DELIVERED, - - /** Сообщение прочитано получателем */ - READ, - - /** Ошибка отправки */ - FAILED, - - /** Удалено локально */ - DELETED, - - /** Неизвестный статус */ - UNKNOWN -} diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt deleted file mode 100644 index 6386ea1..0000000 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ /dev/null @@ -1,55 +0,0 @@ -package chats.domain.repository - -import androidx.paging.PagingData -import chats.domain.model.Chat -import chats.domain.model.Message -import kotlinx.coroutines.flow.Flow - -interface ChatRepository { - // Чаты - fun getChatsFlow(): Flow> - suspend fun getChats(): List - suspend fun syncChats() - - // Сообщения - Single Source of Truth через Flow - fun getMessagesFlow(chatId: String): Flow> - fun getMessagesPagingSource(chatId: String): Flow> - suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List - - // Отправка сообщений с поддержкой офлайн - suspend fun sendMessage( - chatId: String, - content: String?, - type: String = "text", - attachments: List? = null, - replyToId: String? = null, - forwardedFromId: String? = null - ): Message - - suspend fun retryFailedMessage(localId: String) - suspend fun syncMessagesForChat(chatId: String) - suspend fun schedulePeriodicSync() - - // Реакции - suspend fun addReaction(messageId: String, emoji: String) - suspend fun sendTypingStatus(chatId: String) - suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) - suspend fun resetUnreadCount(chatId: String) - - // Локальное хранение - suspend fun saveMessage(message: Message) - suspend fun deleteLocalMessage(messageId: String) - - // Медиа - suspend fun uploadMedia(file: java.io.File): String - suspend fun getTrendingGifs(page: Int = 0): List - suspend fun searchGifs(query: String, page: Int = 0): List - suspend fun getGifCategories(): List - - // Управление чатами - suspend fun getOrCreateFavorites(): Chat - suspend fun createPersonalChat(userId: String): Chat - suspend fun deleteMessage(messageId: String, forEveryone: Boolean) - suspend fun editMessage(messageId: String, content: String): Message -} - diff --git a/client-mobile/chats/domain/usecase/UploadMediaUseCase.kt b/client-mobile/chats/domain/usecase/UploadMediaUseCase.kt deleted file mode 100644 index b7a879a..0000000 --- a/client-mobile/chats/domain/usecase/UploadMediaUseCase.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 1185a36..0000000 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ /dev/null @@ -1,1290 +0,0 @@ -package chats.presentation.chat_detail - -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.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.runtime.* -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.IntOffset -import kotlin.math.roundToInt -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.zIndex -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import chats.presentation.components.MediaPicker -import chats.presentation.components.MessageBubble -import core.presentation.components.AppAvatar -import core.presentation.components.AppMediaLightbox -import core.utils.VoiceRecorder -import core.utils.copyUriToFile -import java.io.File -import ru.knot.messager.R -import kotlinx.coroutines.launch -import android.Manifest -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import androidx.compose.ui.draw.clip -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.ui.draw.shadow -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.ui.unit.sp -import coil.compose.rememberAsyncImagePainter -import androidx.compose.animation.core.* -import androidx.compose.animation.animateContentSize -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale - -import chats.presentation.components.SwipeableMessageItem -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import kotlinx.coroutines.delay - -@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class) -@Composable -fun ChatDetailScreen( - chatId: String, - chatName: String, - viewModel: ChatDetailViewModel, - onBack: () -> Unit -) { - val state by viewModel.state.collectAsState() - val context = LocalContext.current - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - - val voiceRecorder = remember { VoiceRecorder(context) } - var isRecording by remember { mutableStateOf(false) } - - DisposableEffect(Unit) { - onDispose { - viewModel.clearChatId() - if (isRecording) { - voiceRecorder.stopRecording() - } - } - } - - var isEmojiPickerVisible by remember { mutableStateOf(false) } - var recordingOffset by remember { mutableFloatStateOf(0f) } - var isRecordingCanceled by remember { mutableStateOf(false) } - var currentRecordingFile by remember { mutableStateOf(null) } - var recordingStartTime by remember { mutableLongStateOf(0L) } - var recordingElapsedSeconds by remember { mutableIntStateOf(0) } - val recordingScope = rememberCoroutineScope() - val micPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3") - currentRecordingFile = file - voiceRecorder.startRecording(file) - isRecording = true - isRecordingCanceled = false - recordingOffset = 0f - recordingStartTime = System.currentTimeMillis() - recordingElapsedSeconds = 0 - // Запускаем таймер - recordingScope.launch { - while (isRecording && !isRecordingCanceled) { - delay(1000) - recordingElapsedSeconds++ - } - } - } - } - - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - - var autoPlayingMessageId by remember { mutableStateOf(null) } - var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) } - - var selectedMediaList by remember { mutableStateOf?>(null) } - var initialMediaIndex by remember { mutableIntStateOf(0) } - val focusRequester = remember { FocusRequester() } - - // Автоматический фокус при ответе - LaunchedEffect(state.replyingMessage) { - if (state.replyingMessage != null) { - focusRequester.requestFocus() - } - } - - // Пикер галереи (Мультивыбор) - val galleryLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickMultipleVisualMedia() - ) { uris -> - uris.forEach { viewModel.addPendingAttachment(it, context) } - } - - // Загрузка данных чата при входе - LaunchedEffect(chatId) { - viewModel.setChatId(chatId) - } - - // Безопасная группировка сообщений с обработкой ошибок - val listItems = remember(state.messages) { - val items = mutableListOf() - if (state.messages.isEmpty()) return@remember items - - try { - // Удаляем дубликаты по ID сообщения - val uniqueMessages = state.messages.distinctBy { it.id } - if (uniqueMessages.isEmpty()) return@remember items - - // Группируем от старых к новым (для normal layout) - val reversedMessages = uniqueMessages.reversed() - var i = 0 - while (i < reversedMessages.size) { - val isoDate = reversedMessages[i].createdAt - val localDate = viewModel.getLocalDateString(isoDate) - val dayMessages = mutableListOf() - - while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) { - dayMessages.add(reversedMessages[i]) - i++ - } - - // Добавляем заголовок даты - val safeDateHeader = try { - viewModel.formatDateHeader(isoDate) - } catch (e: Exception) { - isoDate.take(10) - } - items.add(MessageListItem.DateHeader(safeDateHeader)) - - // Добавляем сообщения - dayMessages.forEach { msg -> - items.add(MessageListItem.MessageItem(msg)) - } - } - } catch (e: Exception) { - android.util.Log.e("ChatDetailScreen", "Error building listItems", e) - } - items - } - - // Выносим allChatMedia наружу - до LazyColumn - val allChatMedia = remember(state.messages) { - state.messages.flatMap { msg -> msg.media.map { it to msg.id } } - } - - // Функция воспроизведения следующего голосового сообщения - val playNextVoiceMessage = { currentId: String, speed: Float -> - // Находим текущее сообщение в listItems (который от старых к новым) - val currentIndex = listItems.indexOfFirst { - it is MessageListItem.MessageItem && it.message.id == currentId - } - - if (currentIndex != -1 && currentIndex < listItems.size - 1) { - // Ищем следующее голосовое сообщение вперёд по списку (более новое) - val nextVoiceIndexInSublist = listItems.subList(currentIndex + 1, listItems.size) - .indexOfFirst { - it is MessageListItem.MessageItem && - it.message.mediaType == chats.domain.model.MediaType.AUDIO && - it.message.content.isNullOrEmpty() - } - - if (nextVoiceIndexInSublist != -1) { - val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist - val nextItem = listItems[actualNextIndex] as MessageListItem.MessageItem - autoPlayingMessageId = nextItem.message.id - currentPlaybackSpeed = speed - - // Прокручиваем к следующему сообщению, если оно не видно - scope.launch { - val visibleIndexes = listState.layoutInfo.visibleItemsInfo.map { it.index } - if (actualNextIndex !in visibleIndexes) { - listState.animateScrollToItem(actualNextIndex) - } - } - } else { - autoPlayingMessageId = null - } - } else { - autoPlayingMessageId = null - } - } - - // Отслеживаем текущую дату для плавающего заголовка - val floatingDate by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - if (visibleItems.isEmpty()) return@derivedStateOf null - - // Находим первый видимый элемент - val firstVisibleItemIndex = visibleItems.first().index - if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) { - val item = listItems[firstVisibleItemIndex] - if (item is MessageListItem.MessageItem) { - viewModel.formatDateHeader(item.message.createdAt) - } else if (item is MessageListItem.DateHeader) { - item.date - } else null - } else null - } - } - - // Показывать ли кнопку "вниз" - val showScrollToBottom by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - val totalCount = layoutInfo.totalItemsCount - if (totalCount == 0) return@derivedStateOf false - - // Проверяем, видим ли мы последний элемент - val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: 0 - lastVisibleIndex < totalCount - 1 - } - } - - // Авто-скролл к новым сообщениям - только если мы уже внизу - // Берём firstOrNull, т.к. messages отсортированы по sequenceId убыванию (новые в начале) - val lastMessageId = state.messages.firstOrNull()?.id - LaunchedEffect(lastMessageId) { - if (lastMessageId != null && listItems.isNotEmpty()) { - // Даём время LazyColumn отрисовать новое сообщение и обновить layout - delay(200) - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - val totalCount = layoutInfo.totalItemsCount - if (totalCount > 0 && visibleItems.isNotEmpty()) { - val lastVisibleIndex = visibleItems.last().index - val atBottom = lastVisibleIndex >= totalCount - 2 - android.util.Log.d("ChatDetailScreen", "Auto-scroll check: lastVisible=$lastVisibleIndex, total=$totalCount, atBottom=$atBottom, msgId=$lastMessageId") - if (atBottom) { - listState.animateScrollToItem(listItems.size - 1) - } - } - } - } - - // Автоматическая прокрутка при инициализации - только один раз - var initialScrollDone by remember { mutableStateOf(false) } - LaunchedEffect(listItems.size) { - if (!initialScrollDone && listItems.isNotEmpty() && listItems.size > 1) { - try { - listState.scrollToItem(listItems.size - 1) - initialScrollDone = true - viewModel.onInitialScrollDone() - viewModel.markAsRead() - } catch (e: Exception) { - android.util.Log.e("ChatDetailScreen", "Error in auto-scroll", e) - } - } - } - - // Подгрузка истории при прокрутке вверх - LaunchedEffect(listState) { - snapshotFlow { listState.layoutInfo } - .collect { layoutInfo -> - val visibleItems = layoutInfo.visibleItemsInfo - val totalCount = layoutInfo.totalItemsCount - if (totalCount == 0 || visibleItems.isEmpty()) return@collect - - val firstVisibleIndex = visibleItems.first().index - if (firstVisibleIndex < 5 && !state.isLoadingMore) { - android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex") - viewModel.loadMoreMessages() - } - } - } - - val unreadCount = remember(state.messages) { - state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } - } - - // Авто-прочитка при открытии чата - если мы внизу - LaunchedEffect(Unit) { - delay(500) - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - val totalCount = layoutInfo.totalItemsCount - if (totalCount > 0 && visibleItems.isNotEmpty()) { - val lastVisibleIndex = visibleItems.last().index - if (lastVisibleIndex >= totalCount - 2) { - viewModel.markAsRead() - } - } - } - - // Авто-прочитка при нахождении внизу списка - LaunchedEffect(listState.isScrollInProgress) { - if (!listState.isScrollInProgress) { - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - val totalCount = layoutInfo.totalItemsCount - if (totalCount > 0 && visibleItems.isNotEmpty()) { - val lastVisibleIndex = visibleItems.last().index - if (lastVisibleIndex >= totalCount - 2) { - viewModel.markAsRead() - } - } - } - } - - Scaffold( - topBar = { - if (state.selectedMessageIds.isNotEmpty()) { - TopAppBar( - title = { Text("${state.selectedMessageIds.size}") }, - navigationIcon = { - IconButton(onClick = { viewModel.clearSelection() }) { - Icon(Icons.Default.Close, contentDescription = "Cancel") - } - }, - actions = { - IconButton(onClick = { - viewModel.onForwardSelectedMessages() - }) { - Icon(Icons.Default.Forward, contentDescription = "Forward") - } - IconButton(onClick = { - // TODO: Bulk Delete - }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") - } - } - ) - } else { - TopAppBar( - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - AppAvatar( - url = state.chatAvatar, - name = state.chatName ?: chatName, - size = 36.dp, - modifier = Modifier.padding(end = 8.dp) - ) - Column { - Text(state.chatName ?: 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) - .imePadding() - ) { - // Список сообщений - 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( - items = listItems, - key = { item -> - when(item) { - is MessageListItem.MessageItem -> "msg_${item.message.id}" - is MessageListItem.DateHeader -> "date_${item.date}" - MessageListItem.UnreadSeparator -> "unread_separator" - } - }, - contentType = { item -> - when(item) { - is MessageListItem.MessageItem -> "message" - is MessageListItem.DateHeader -> "date_header" - MessageListItem.UnreadSeparator -> "unread_separator" - } - } - ) { item -> - when(item) { - is MessageListItem.MessageItem -> { - SwipeableMessageItem( - onReply = { - viewModel.onReply(item.message) - } - ) { - MessageBubble( - message = item.message, - isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(), - autoPlay = item.message.id == autoPlayingMessageId, - initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, - onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) }, - onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) }, - onReply = { msg -> viewModel.onReply(msg) }, - onReplyClick = { reply -> - val index = listItems.indexOfFirst { - it is MessageListItem.MessageItem && it.message.id == reply.id - } - if (index != -1) { - scope.launch { listState.animateScrollToItem(index) } - } - }, - onSelect = { viewModel.toggleSelection(it) }, - onForward = { viewModel.onForward(it) }, - onPin = { viewModel.onPin(it) }, - onEdit = { viewModel.onEdit(it) }, - onDelete = { msg, forEveryone -> viewModel.deleteMessage(msg, forEveryone) }, - isSelected = state.selectedMessageIds.contains(item.message.id), - isSelectionMode = state.selectedMessageIds.isNotEmpty(), - onMediaClick = { clickedMedia -> - val isMedia = clickedMedia.type.startsWith("image") || - clickedMedia.type.startsWith("video") || - clickedMedia.type.contains("gif") - - if (isMedia) { - val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } - selectedMediaList = allChatMedia.map { it.first } - initialMediaIndex = if (initialIndex != -1) initialIndex else 0 - } else { - try { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url)) - context.startActivity(intent) - } catch (e: Exception) { } - } - } - ) - } - } - is MessageListItem.DateHeader -> { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - contentAlignment = Alignment.Center - ) { - Surface( - color = Color.Black.copy(alpha = 0.2f), - shape = RoundedCornerShape(12.dp) - ) { - Text( - text = item.date, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = Color.White - ) - } - } - } - MessageListItem.UnreadSeparator -> { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f))) - Text( - text = "НЕПРОЧИТАННЫЕ СООБЩЕНИЯ", - modifier = Modifier.padding(horizontal = 12.dp), - style = MaterialTheme.typography.labelSmall, - color = Color.Gray.copy(alpha = 0.6f) - ) - Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f))) - } - } - } - } - } - - } - - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - - // Плавающий заголовок даты (на самом верху списка под топбаром) - floatingDate?.let { date -> - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .align(Alignment.TopCenter) - .zIndex(1f), // Гарантируем видимость поверх всего - contentAlignment = Alignment.TopCenter - ) { - Surface( - color = Color.DarkGray.copy(alpha = 0.9f), - shape = CircleShape, - shadowElevation = 4.dp - ) { - Text( - text = date, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = Color.White - ) - } - } - } - - // Кнопка прокрутки вниз с бейджем - if (showScrollToBottom) { - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 16.dp, end = 16.dp) - ) { - Box( - modifier = Modifier - .size(44.dp) - .shadow(8.dp, CircleShape) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) - .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) - .clickable { - scope.launch { - listState.animateScrollToItem(listItems.size - 1) - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(28.dp) - ) - } - - if (unreadCount > 0) { - Surface( - color = Color(0xFFF44336), // Красный как в вебе - shape = CircleShape, - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - ) { - Text( - text = if (unreadCount > 99) "99+" else unreadCount.toString(), - color = Color.White, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - } - } - - // Панель ввода + оверлей записи - Box(modifier = Modifier.fillMaxWidth()) { - // Основная панель - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) { - // Pending Attachments Bar - if (state.pendingAttachments.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Вложения (${state.pendingAttachments.size})", - style = MaterialTheme.typography.labelMedium, - color = Color.Gray, - modifier = Modifier.weight(1f) - ) - - // HQ Toggle moved here - if (state.pendingAttachments.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp") }) { - TextButton( - onClick = { viewModel.toggleCompression() }, - modifier = Modifier.height(30.dp), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) - ) { - Icon( - imageVector = if (!state.isCompressionEnabled) Icons.Default.HighQuality else Icons.Default.Hd, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray - ) - Spacer(Modifier.width(4.dp)) - Text( - text = if (!state.isCompressionEnabled) "Без сжатия" else "Сжать (HD)", - fontSize = 11.sp, - color = if (!state.isCompressionEnabled) MaterialTheme.colorScheme.primary else Color.Gray - ) - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - androidx.compose.foundation.lazy.LazyRow( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(state.pendingAttachments) { file -> - Box( - modifier = Modifier - .size(80.dp) - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - val isImage = file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") - val isVideo = file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") - - if (isImage || isVideo) { - androidx.compose.foundation.Image( - painter = rememberAsyncImagePainter(file), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = androidx.compose.ui.layout.ContentScale.Crop - ) - if (isVideo) { - Icon( - Icons.Default.PlayCircle, - contentDescription = null, - modifier = Modifier.align(Alignment.Center).size(32.dp), - tint = Color.White.copy(alpha = 0.8f) - ) - } - } else { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.InsertDriveFile, - contentDescription = null, - modifier = Modifier.size(32.dp) - ) - Text( - text = file.extension.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Кнопка закрытия (Centered properly) - Surface( - onClick = { viewModel.removePendingAttachment(file) }, - color = Color.Black.copy(alpha = 0.5f), - shape = CircleShape, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(4.dp) - .size(20.dp) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Remove", - tint = Color.White, - modifier = Modifier.padding(4.dp) - ) - } - } - } - } - - if (state.isUploading) { - CircularProgressIndicator(modifier = Modifier.size(24.dp).padding(4.dp)) - } - } - } - // Reply Preview Bar - state.replyingMessage?.let { replyMsg -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(2.dp) - .background(MaterialTheme.colorScheme.primary) - ) - Icon( - imageVector = Icons.Default.Reply, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp).size(20.dp) - ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - val replyPrefix = stringResource(R.string.reply_prefix) - val replySelf = stringResource(R.string.reply_self) - - Text( - text = replyPrefix + (if (replyMsg.senderId == viewModel.getCurrentUserId()) replySelf else replyMsg.senderName), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - - val photoText = stringResource(R.string.reply_photo) - val videoText = stringResource(R.string.reply_video) - val voiceText = stringResource(R.string.voice_message) - val audioText = stringResource(R.string.reply_audio) - val fileText = stringResource(R.string.reply_file) - val gifText = stringResource(R.string.reply_gif) - val mediaText = stringResource(R.string.media) - - val replyText = remember(replyMsg) { - if (!replyMsg.content.isNullOrEmpty()) { - replyMsg.content - } else { - when (replyMsg.mediaType) { - chats.domain.model.MediaType.IMAGE -> photoText - chats.domain.model.MediaType.VIDEO -> videoText - chats.domain.model.MediaType.AUDIO -> if (replyMsg.media.any { it.type == "voice" }) voiceText else audioText - chats.domain.model.MediaType.FILE -> fileText - chats.domain.model.MediaType.GIF -> gifText - else -> mediaText - } - } - } - - Text( - text = replyText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - if ((replyMsg.mediaType == chats.domain.model.MediaType.IMAGE || - replyMsg.mediaType == chats.domain.model.MediaType.VIDEO || - replyMsg.mediaType == chats.domain.model.MediaType.GIF) && - replyMsg.media.isNotEmpty()) { - androidx.compose.foundation.Image( - painter = rememberAsyncImagePainter(replyMsg.media.first().url), - contentDescription = null, - modifier = Modifier - .padding(end = 4.dp) - .size(36.dp) - .clip(RoundedCornerShape(4.dp)), - contentScale = androidx.compose.ui.layout.ContentScale.Crop - ) - } - - IconButton(onClick = { viewModel.cancelReply() }) { - Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray) - } - } - } - - // Edit Preview Bar - state.editingMessage?.let { editMsg -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(2.dp) - .background(MaterialTheme.colorScheme.primary) - ) - Icon( - imageVector = Icons.Default.Edit, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp).size(20.dp) - ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = "Редактирование", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - Text( - text = editMsg.content ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - IconButton(onClick = { viewModel.cancelEdit() }) { - Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray) - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // 1. Скрепка (слева) - IconButton(onClick = { - galleryLauncher.launch( - androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) - ) - }) { - Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach), tint = Color.Gray) - } - - // 2. Поле ввода + Эмодзи (в одном баббле) - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)) - .padding(horizontal = 4.dp, vertical = 4.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = { - isEmojiPickerVisible = !isEmojiPickerVisible - if (isEmojiPickerVisible) { - keyboardController?.hide() - focusManager.clearFocus() - } - } - ) { - Icon( - Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.emoji), - tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray, - modifier = Modifier.size(24.dp) - ) - } - - BasicTextField( - value = state.inputText, - onValueChange = { viewModel.onInputTextChanged(it) }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - isEmojiPickerVisible = false - } - }, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), - cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Default, - capitalization = KeyboardCapitalization.Sentences - ), - maxLines = 6, - decorationBox = { innerTextField -> - if (state.inputText.isEmpty()) { - Text( - stringResource(R.string.message_placeholder), - style = MaterialTheme.typography.bodyLarge, - color = Color.Gray - ) - } - innerTextField() - } - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - // 3. Кнопка действия (в синем квадрате) - val showMic = state.inputText.isEmpty() && state.pendingAttachments.isEmpty() - - // Анимация пульсации для кнопки записи - val pulseProgress = remember { Animatable(0f) } - LaunchedEffect(isRecording) { - if (isRecording) { - pulseProgress.animateTo( - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000), - repeatMode = RepeatMode.Reverse - ) - ) - } else { - pulseProgress.snapTo(0f) - } - } - - val micScale by animateFloatAsState( - targetValue = if (isRecording) 1f + (pulseProgress.value * 0.15f) else 1f, - label = "micScale" - ) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(48.dp) - .scale(if (showMic) micScale else 1f) - .clip(RoundedCornerShape(16.dp)) - .background(if (showMic) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary) - .clickable(enabled = !showMic) { - viewModel.sendMessage() - isEmojiPickerVisible = false - } - .pointerInput(showMic) { - if (showMic) { - detectDragGesturesAfterLongPress( - onDragStart = { - android.util.Log.d("ChatDetailScreen", "onDragStart triggered") - val hasMic = ContextCompat.checkSelfPermission( - context, Manifest.permission.RECORD_AUDIO - ) == PackageManager.PERMISSION_GRANTED - android.util.Log.d("ChatDetailScreen", "Permission RECORD_AUDIO: $hasMic") - if (hasMic) { - try { - val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3") - currentRecordingFile = file - android.util.Log.d("ChatDetailScreen", "Starting recording to: ${file.absolutePath}") - voiceRecorder.startRecording(file) - isRecording = true - isRecordingCanceled = false - recordingOffset = 0f - recordingStartTime = System.currentTimeMillis() - recordingElapsedSeconds = 0 - // Запускаем таймер - recordingScope.launch { - while (isRecording && !isRecordingCanceled) { - delay(1000) - recordingElapsedSeconds++ - } - } - } catch (e: Exception) { - android.util.Log.e("ChatDetailScreen", "Error starting recording", e) - } - } else { - android.util.Log.d("ChatDetailScreen", "Requesting mic permission") - micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - }, - onDrag = { change, dragAmount -> - change.consume() - recordingOffset += dragAmount.x - if (recordingOffset < -100f) { - isRecordingCanceled = true - } - }, - onDragEnd = { - android.util.Log.d("ChatDetailScreen", "onDragEnd: isRecording=$isRecording, isRecordingCanceled=$isRecordingCanceled") - if (isRecording) { - voiceRecorder.stopRecording() - if (!isRecordingCanceled) { - currentRecordingFile?.let { - android.util.Log.d("ChatDetailScreen", "Sending voice message file: ${it.absolutePath}") - viewModel.sendVoiceMessage(it) - } ?: android.util.Log.e("ChatDetailScreen", "currentRecordingFile is null") - } else { - android.util.Log.d("ChatDetailScreen", "Recording canceled") - currentRecordingFile?.delete() - } - isRecording = false - isRecordingCanceled = false - recordingOffset = 0f - recordingElapsedSeconds = 0 - } - }, - onDragCancel = { - android.util.Log.d("ChatDetailScreen", "onDragCancel: isRecording=$isRecording") - if (isRecording) { - voiceRecorder.stopRecording() - currentRecordingFile?.delete() - isRecording = false - isRecordingCanceled = false - recordingOffset = 0f - recordingElapsedSeconds = 0 - } - } - ) - } - } - ) { - Icon( - imageVector = if (showMic) (if (isRecording) Icons.Default.Mic else Icons.Default.MicNone) else Icons.Default.Send, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } - } - - if (isEmojiPickerVisible) { - LaunchedEffect(Unit) { - viewModel.loadTrendingGifs() - } - MediaPicker( - trendingGifs = state.trendingGifs, - searchedGifs = state.searchedGifs, - recentGifs = state.recentGifs, - gifCategories = state.gifCategories, - isGifsLoading = state.isGifsLoading, - error = state.error, - onEmojiSelected = { viewModel.onInputTextChanged(state.inputText + it) }, - onGifSelected = { url -> - viewModel.sendGif(url) - isEmojiPickerVisible = false - }, - onGifSearch = { query -> - viewModel.searchGifs(query) - } - ) - - LaunchedEffect(Unit) { - viewModel.loadTrendingGifs() - viewModel.loadGifCategories() - } - } - } - - // Оверлей записи - ПОВЕРХ всего (внутри BoxScope) - if (isRecording) { - val cancelIconScale by animateFloatAsState( - targetValue = if (recordingOffset < -50f) { - 1f + ((-recordingOffset - 50f) / 150f).coerceIn(0f, 1f) - } else 0.8f, - label = "cancelScale" - ) - - val cancelIconAlpha by animateFloatAsState( - targetValue = if (recordingOffset < -50f) 1f else 0.5f, - label = "cancelAlpha" - ) - - val formattedTime = String.format("%02d:%02d", recordingElapsedSeconds / 60, recordingElapsedSeconds % 60) - - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .background(Color.Black) - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(48.dp) - .scale(cancelIconScale) - .alpha(cancelIconAlpha), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background( - color = Color.Red.copy(alpha = 0.3f + (cancelIconAlpha * 0.4f)), - shape = CircleShape - ) - ) - Icon( - Icons.Default.Close, - contentDescription = "Cancel recording", - tint = Color.Red, - modifier = Modifier.size(32.dp) - ) - } - - Box( - modifier = Modifier - .background( - color = Color.Red.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 20.dp, vertical = 10.dp), - contentAlignment = Alignment.Center - ) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - PulseDot() - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = formattedTime, - style = MaterialTheme.typography.titleLarge, - color = Color.White, - fontWeight = FontWeight.SemiBold - ) - } - } - - Spacer(modifier = Modifier.size(48.dp)) - } - } - } - } - } - } - - // Просмотрщик медиа - selectedMediaList?.let { list -> - AppMediaLightbox( - mediaList = list, - initialIndex = initialMediaIndex, - onClose = { selectedMediaList = null } - ) - } - - if (state.forwardingMessages.isNotEmpty()) { - ForwardChatSelectionDialog( - chats = state.availableChatsToForward, - onChatSelected = { targetChatId -> - viewModel.forwardMessages(targetChatId, state.forwardingMessages) - viewModel.cancelForwarding() - viewModel.clearSelection() - }, - onDismiss = { viewModel.cancelForwarding() } - ) - } -} - -@Composable -fun ForwardChatSelectionDialog( - chats: List, - onChatSelected: (String) -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Выберите чат для пересылки") }, - text = { - LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp)) { - items(chats) { chat -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onChatSelected(chat.id) } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppAvatar(url = chat.avatar, name = chat.name, size = 40.dp) - Spacer(Modifier.width(12.dp)) - Text(chat.name, style = MaterialTheme.typography.bodyLarge) - } - Divider(color = Color.Gray.copy(alpha = 0.2f)) - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Отмена") } - } - ) -} - -@Immutable -sealed class MessageListItem { - data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() - data class DateHeader(val date: String) : MessageListItem() - object UnreadSeparator : MessageListItem() -} - -@Composable -fun PulseDot() { - val infiniteTransition = rememberInfiniteTransition(label = "pulse") - val scale by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 1.5f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000), - repeatMode = RepeatMode.Reverse - ), - label = "scale" - ) - - val alpha by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000), - repeatMode = RepeatMode.Reverse - ), - label = "alpha" - ) - - Box( - modifier = Modifier.size(12.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(12.dp) - .background(color = Color.Red, shape = CircleShape) - .alpha(alpha) - ) - Box( - modifier = Modifier - .size(8.dp) - .background(color = Color.Red, shape = CircleShape) - ) - } -} diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt deleted file mode 100644 index 213732d..0000000 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ /dev/null @@ -1,793 +0,0 @@ -package chats.presentation.chat_detail - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import android.util.Log -import chats.data.remote.signalr.ChatEvent -import chats.data.remote.signalr.ChatHubClient -import chats.data.repository.toDomain -import chats.domain.model.Message -import chats.domain.repository.ChatRepository -import core.network.ServerConfig -import core.security.TokenManager -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 -import core.utils.copyUriToFile -import core.utils.ImageUtils - -import chats.data.remote.api.KlipyGifDto -import chats.domain.model.MessageStatus - -data class ChatDetailState( - val messages: List = emptyList(), - val chatName: String? = null, - val chatAvatar: String? = null, - val isLoading: Boolean = false, - val isLoadingMore: Boolean = false, - val isTyping: Boolean = false, - val typingUser: String? = null, - val error: String? = null, - val canCall: Boolean = true, - val maxFileSize: Long = 100 * 1024 * 1024, - val trendingGifs: List = emptyList(), - val searchedGifs: List = emptyList(), - val recentGifs: List = emptyList(), - val gifCategories: List = emptyList(), - val isGifsLoading: Boolean = false, - val initialScrollIndex: Int? = null, - val pendingAttachments: List = emptyList(), - val isUploading: Boolean = false, - val isCompressionEnabled: Boolean = true, - val inputText: String = "", - val replyingMessage: Message? = null, - val editingMessage: Message? = null, - val forwardingMessages: List = emptyList(), - val availableChatsToForward: List = emptyList(), - val selectedMessageIds: Set = emptySet(), - val pinnedMessages: List = emptyList(), - val isOffline: Boolean = false -) - -@HiltViewModel -class ChatDetailViewModel @Inject constructor( - private val repository: ChatRepository, - private val signalrClient: ChatHubClient, - private val serverConfig: ServerConfig, - private val tokenManager: TokenManager, - private val activeChatTracker: core.notifications.data.ActiveChatTracker, - private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver, - @dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context -) : ViewModel() { - - private val _state = MutableStateFlow(ChatDetailState()) - val state: StateFlow = _state.asStateFlow() - - private var currentChatId: String? = null - private var typingTimerJob: Job? = null - private var signalrEventsJob: Job? = null - private var lastTypingSentTime: Long = 0 - - private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE) - - init { - val config = serverConfig.getServerConfig() - val savedCompression = prefs.getBoolean("compression_enabled", true) - _state.update { it.copy( - canCall = config.features.calls, - maxFileSize = config.limits.maxFileSize, - isCompressionEnabled = savedCompression - ) } - } - - fun toggleCompression() { - _state.update { currentState -> - val newValue = !currentState.isCompressionEnabled - prefs.edit().putBoolean("compression_enabled", newValue).apply() - currentState.copy(isCompressionEnabled = newValue) - } - } - - fun getCurrentUserId(): String { - return tokenManager.getUserId() ?: "" - } - - private var messagesFlowJob: Job? = null - - fun setChatId(chatId: String) { - if (currentChatId == chatId) return - currentChatId = chatId - activeChatTracker.setChatId(chatId) - - _state.update { it.copy( - messages = emptyList(), - isLoading = true, - initialScrollIndex = null - ) } - - // Ensure SignalR is connected and join the chat room - val token = tokenManager.getToken() - val baseUrl = serverConfig.getBaseUrl() - if (token != null && baseUrl.isNotBlank()) { - signalrClient.connect(baseUrl.removeSuffix("/api/"), token) - signalrClient.joinChat(chatId) - } - - loadChatInfo(chatId) - observeSignalREvents(chatId) - - // Подписываемся на Flow из Room (Single Source of Truth) - observeMessagesFromLocal(chatId) - - // Немедленно грузим историю с сервера, чтобы не ждать WorkManager - viewModelScope.launch { - try { - repository.getMessages(chatId, limit = 50) - } catch (e: Exception) { - android.util.Log.e("ChatDetailVM", "Initial messages load failed", e) - } - } - - // Дополнительно ставим фоновую синхронизацию через WorkManager - viewModelScope.launch { - repository.syncMessagesForChat(chatId) - } - } - - /** - * Подписка на локальные сообщения из Room (Single Source of Truth) - */ - private fun observeMessagesFromLocal(chatId: String) { - messagesFlowJob?.cancel() - messagesFlowJob = viewModelScope.launch { - repository.getMessagesFlow(chatId) - .catch { e -> - Log.e("ChatDetailVM", "Error observing messages", e) - _state.update { it.copy(isLoading = false, error = e.message) } - } - .collect { messages -> - val sortedMessages = messages.sortedByDescending { it.sequenceId } - _state.update { currentState -> - currentState.copy( - messages = sortedMessages, - isLoading = false, - initialScrollIndex = if (currentState.initialScrollIndex == null) 0 else currentState.initialScrollIndex - ) - } - } - } - } - - fun onInitialScrollDone() { - _state.update { it.copy(initialScrollIndex = -1) } - } - - fun loadMoreMessages() { - val chatId = currentChatId ?: return - if (_state.value.isLoading || _state.value.isLoadingMore) return - - val oldestMsg = _state.value.messages.lastOrNull() ?: return - - viewModelScope.launch { - _state.update { it.copy(isLoadingMore = true) } - try { - android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}") - val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString()) - if (moreMessages.isNotEmpty()) { - val newSorted = moreMessages.sortedByDescending { it.sequenceId } - _state.update { currentState -> - // Избегаем дубликатов - val existingIds = currentState.messages.map { it.id }.toSet() - val uniqueMore = newSorted.filter { it.id !in existingIds } - currentState.copy(messages = currentState.messages + uniqueMore) - } - } - } finally { - _state.update { it.copy(isLoadingMore = false) } - } - } - } - - private fun loadChatInfo(chatId: String) { - viewModelScope.launch { - try { - val chats = repository.getChats() - val chat = chats.find { it.id == chatId } - chat?.let { c -> - _state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) } - } - } catch (e: Exception) { - // Ignore info load error - } - } - } - - private fun updateMessageReaction(messageId: String, userId: String, emoji: String) { - _state.update { s -> - val updatedMessages = s.messages.map { msg -> - if (msg.id == messageId) { - val currentReactions = msg.reactions.toMutableMap() - currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1 - msg.copy(reactions = currentReactions) - } else msg - } - s.copy(messages = updatedMessages) - } - } - - // For handling reaction removed event - private fun removeMessageReaction(messageId: String, userId: String, emoji: String) { - _state.update { s -> - val updatedMessages = s.messages.map { msg -> - if (msg.id == messageId) { - val currentReactions = msg.reactions.toMutableMap() - val count = currentReactions[emoji] ?: 0 - if (count > 1) { - currentReactions[emoji] = count - 1 - } else { - currentReactions.remove(emoji) - } - msg.copy(reactions = currentReactions) - } else msg - } - s.copy(messages = updatedMessages) - } - } - - private fun observeSignalREvents(chatId: String) { - signalrEventsJob?.cancel() - signalrEventsJob = signalrClient.events - .onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") } - .filter { event -> - val eventChatId = when(event) { - is ChatEvent.NewMessage -> event.message.chatId - is ChatEvent.ReactionUpdated -> event.chatId - is ChatEvent.UserTyping -> event.chatId - is ChatEvent.MessagesRead -> event.chatId - else -> null - } - - if (eventChatId == null) return@filter false - - val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId) - if (match) { - android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event") - } - match - } - .onEach { event -> - when (event) { - is ChatEvent.NewMessage -> { - android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically") - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl) - - _state.update { currentState -> - if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState - currentState.copy(messages = listOf(domainMsg) + currentState.messages) - } - } - is ChatEvent.ReactionUpdated -> { - if (event.isRemoved) { - removeMessageReaction(event.messageId, event.userId, event.emoji) - } else { - updateMessageReaction(event.messageId, event.userId, event.emoji) - } - } - is ChatEvent.MessageDeleted -> { - _state.update { currentState -> - currentState.copy(messages = currentState.messages.filter { it.id != event.messageId }) - } - } - is ChatEvent.MessageEdited -> { - _state.update { currentState -> - val updated = currentState.messages.map { msg -> - if (msg.id == event.messageId) msg.copy(content = event.content) else msg - } - currentState.copy(messages = updated) - } - } - is ChatEvent.UserTyping -> { - _state.update { it.copy(isTyping = true) } - typingTimerJob?.cancel() - typingTimerJob = viewModelScope.launch { - delay(3000) - _state.update { it.copy(isTyping = false) } - } - } - is ChatEvent.UserStoppedTyping -> { - _state.update { it.copy(isTyping = false) } - } - is ChatEvent.MessagesRead -> { - _state.update { currentState -> - val updatedMessages = currentState.messages.map { msg -> - if (msg.sequenceId <= event.lastReadSequenceId) { - msg.copy(isRead = true) - } else msg - } - currentState.copy(messages = updatedMessages) - } - } - is ChatEvent.MessagePinned -> { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - _state.update { s -> - val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl) - s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id }) - } - } - is ChatEvent.MessageUnpinned -> { - _state.update { s -> - s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId }) - } - } - else -> Unit - } - } - .launchIn(viewModelScope) - } - - fun clearChatId() { - android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId") - signalrEventsJob?.cancel() - signalrEventsJob = null - currentChatId = null - activeChatTracker.setChatId(null) - _state.update { it.copy(messages = emptyList(), isLoading = false) } - } - - fun markAsRead() { - val chatId = currentChatId ?: return - val messages = _state.value.messages - if (messages.isEmpty()) return - - // В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника - val currentUserId = getCurrentUserId() - val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return - - viewModelScope.launch { - try { - // Мгновенно обновляем в памяти для "галочек" - _state.update { currentState -> - val updatedMessages = currentState.messages.map { msg -> - if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) { - msg.copy(isRead = true) - } else msg - } - currentState.copy(messages = updatedMessages) - } - repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId) - repository.resetUnreadCount(chatId) - signalrNotificationObserver.refresh() - } catch (e: Exception) { - // Ignore - } - } - } - - fun onInputTextChanged(text: String) { - _state.update { it.copy(inputText = text) } - - val chatId = currentChatId ?: return - - // Отправляем индикатор набора текста - val now = System.currentTimeMillis() - if (now - lastTypingSentTime > 1000) { - signalrClient.sendTypingIndicator(chatId) - lastTypingSentTime = now - - // Отправляем "остановился печатать" через 3 секунды - typingTimerJob?.cancel() - typingTimerJob = viewModelScope.launch { - delay(3000) - signalrClient.sendUserStoppedTyping(chatId) - } - } - } - - - fun onReply(message: Message) { - _state.update { it.copy(replyingMessage = message) } - } - - fun forwardMessages(targetChatId: String, messages: List) { - viewModelScope.launch { - messages.forEach { msg -> - repository.sendMessage( - chatId = targetChatId, - content = msg.content, - type = msg.mediaType.name.lowercase(), - forwardedFromId = msg.senderId - ) - } - } - } - - fun onForward(message: Message) { - _state.update { it.copy(forwardingMessages = listOf(message)) } - loadChatsForForwarding() - } - - fun loadChatsForForwarding() { - viewModelScope.launch { - try { - val chats = repository.getChats() - _state.update { it.copy(availableChatsToForward = chats) } - } catch (e: Exception) { - // Ignore - } - } - } - - fun cancelForwarding() { - _state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) } - } - - fun onForwardSelectedMessages() { - val selectedIds = _state.value.selectedMessageIds - val messages = _state.value.messages.filter { selectedIds.contains(it.id) } - _state.update { it.copy(forwardingMessages = messages) } - loadChatsForForwarding() - } - - fun toggleSelection(messageId: String) { - _state.update { s -> - val newSelection = if (s.selectedMessageIds.contains(messageId)) { - s.selectedMessageIds - messageId - } else { - s.selectedMessageIds + messageId - } - s.copy(selectedMessageIds = newSelection) - } - } - - fun clearSelection() { - _state.update { it.copy(selectedMessageIds = emptySet()) } - } - - fun cancelReply() { - _state.update { it.copy(replyingMessage = null) } - } - - fun cancelEdit() { - _state.update { it.copy(editingMessage = null, inputText = "") } - } - - fun deleteMessage(message: Message, forEveryone: Boolean) { - viewModelScope.launch { - try { - repository.deleteMessage(message.id, forEveryone) - // Local update if needed (will also come via SignalR for everyone, but forMe might need local only update) - _state.update { currentState -> - currentState.copy(messages = currentState.messages.filter { it.id != message.id }) - } - } catch (e: Exception) { - _state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") } - } - } - } - - fun onEdit(message: Message) { - _state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) } - } - - fun pinMessage(messageId: String) { - val chatId = currentChatId ?: return - signalrClient.pinMessage(messageId, chatId) - } - - fun unpinMessage(messageId: String) { - val chatId = currentChatId ?: return - signalrClient.unpinMessage(messageId, chatId) - } - - fun onPin(message: Message) { - pinMessage(message.id) - } - - fun sendMessage(onFail: (String) -> Unit = {}) { - val chatId = currentChatId ?: return - val text = _state.value.inputText - val pending = _state.value.pendingAttachments - val replyToId = _state.value.replyingMessage?.id - val editingMsg = _state.value.editingMessage - if (text.isBlank() && pending.isEmpty()) return - - // Handle Edit - if (editingMsg != null) { - _state.update { it.copy(inputText = "", editingMessage = null) } - viewModelScope.launch { - try { - val updated = repository.editMessage(editingMsg.id, text) - _state.update { currentState -> - val updatedList = currentState.messages.map { - if (it.id == updated.id) updated else it - } - currentState.copy(messages = updatedList) - } - } catch (e: Exception) { - _state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") } - onFail(text) - } - } - return - } - - // Clear input immediately для отзывчивого UI - _state.update { it.copy(inputText = "", replyingMessage = null, pendingAttachments = emptyList()) } - - // Определяем тип медиа - val mediaType = when { - pending.isEmpty() -> "text" - pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> "image" - pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> "video" - pending.any { it.extension.lowercase() in listOf("mp3", "m4a", "wav") } -> "audio" - else -> "text" - } - - viewModelScope.launch { - try { - // Upload attachments если есть - val attachmentRequests = if (pending.isNotEmpty()) { - _state.update { it.copy(isUploading = true) } - val requests = pending.map { file -> - val url = repository.uploadMedia(file) - chats.data.remote.api.AttachmentRequest( - type = when { - file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image" - file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video" - file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio" - else -> "file" - }, - url = url, - fileName = file.name, - fileSize = file.length() - ) - } - _state.update { it.copy(isUploading = false) } - requests - } else { - null - } - - // Отправляем сообщение - теперь оно создается локально и ставится в очередь - repository.sendMessage( - chatId = chatId, - content = if (text.isBlank()) null else text, - type = mediaType, - attachments = attachmentRequests, - replyToId = replyToId - ) - // Сообщение автоматически появится в UI через Flow из Room - } catch (e: Exception) { - _state.update { it.copy(error = e.localizedMessage, isUploading = false) } - onFail(text) - } - } - } - - /** - * Повторная отправка неудачного сообщения - */ - fun retryMessage(messageId: String) { - viewModelScope.launch { - repository.retryFailedMessage(messageId) - } - } - - /** - * Удаление локального сообщения (отмена отправки) - */ - fun cancelMessage(messageId: String) { - viewModelScope.launch { - repository.deleteLocalMessage(messageId) - } - } - - fun addReaction(messageId: String, emoji: String) { - val chatId = currentChatId ?: return - - // We rely on SignalR event to update the count to avoid double counting - // especially since domain Message doesn't track user IDs for reactions yet. - signalrClient.addReaction(messageId, chatId, emoji) - } - - fun sendVoiceMessage(file: File) { - android.util.Log.d("ChatDetailVM", "sendVoiceMessage called with file: ${file.absolutePath}, size: ${file.length()}") - val chatId = currentChatId ?: run { - android.util.Log.e("ChatDetailVM", "sendVoiceMessage failed: currentChatId is null") - return - } - val replyToId = _state.value.replyingMessage?.id - _state.update { it.copy(replyingMessage = null) } - - viewModelScope.launch { - try { - android.util.Log.d("ChatDetailVM", "Starting voice upload...") - val url = repository.uploadMedia(file) - android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url") - - val attachment = chats.data.remote.api.AttachmentRequest( - type = "voice", - url = url, - fileName = file.name, - fileSize = file.length() - ) - - android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...") - repository.sendMessage( - chatId = chatId, - content = null, - type = "media", - attachments = listOf(attachment), - replyToId = replyToId - ) - // Сообщение автоматически появится в UI через Flow из Room - android.util.Log.d("ChatDetailVM", "Voice message queued successfully") - } catch (e: Exception) { - android.util.Log.e("ChatDetailVM", "Error sending voice message", e) - _state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") } - } - } - } - - fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) { - viewModelScope.launch { - val mimeType = context.contentResolver.getType(uri) ?: "" - val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) { - ImageUtils.compressImage(context, uri) - } else { - copyUriToFile(context, uri) - } - - file?.let { f -> - _state.update { it.copy(pendingAttachments = it.pendingAttachments + f) } - } - } - } - - fun formatDateHeader(dateString: String): String { - return try { - if (dateString.isBlank()) return "Unknown" - // Парсим ISO 8001 (например 2024-04-14T20:56:00Z) - val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - } - val date = isoFormat.parse(dateString) ?: return dateString - - // Форматируем в локальное время: "14 апреля" - java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date) - } catch (e: Exception) { - android.util.Log.e("ChatDetailVM", "formatDateHeader error: ${e.message}", e) - dateString.take(10) - } - } - - fun getLocalDateString(isoDate: String): String { - return try { - if (isoDate.isBlank()) return "1970-01-01" - val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - } - val date = isoFormat.parse(isoDate) ?: return isoDate.take(10) - // Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки - java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date) - } catch (e: Exception) { - android.util.Log.e("ChatDetailVM", "getLocalDateString error: ${e.message}", e) - isoDate.split("T").firstOrNull() ?: "1970-01-01" - } - } - - fun removePendingAttachment(file: File) { - _state.update { it.copy(pendingAttachments = it.pendingAttachments - file) } - } - - fun uploadMedia(file: File) { - if (file.length() > _state.value.maxFileSize) { - _state.update { it.copy(error = "File too large") } - return - } - viewModelScope.launch { - try { - val url = repository.uploadMedia(file) - // After upload, we might want to send a message with this media - // For now, let's just log it or handle as per app requirements - } catch (e: Exception) { - _state.update { it.copy(error = e.localizedMessage) } - } - } - } - - fun loadTrendingGifs() { - if (_state.value.trendingGifs.isNotEmpty()) return - - viewModelScope.launch { - _state.update { it.copy(isGifsLoading = true) } - try { - val gifs = repository.getTrendingGifs(0) - _state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) } - } catch (e: Exception) { - _state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") } - } - } - } - - fun loadGifCategories() { - if (_state.value.gifCategories.isNotEmpty()) return - viewModelScope.launch { - try { - val categories = repository.getGifCategories() - _state.update { it.copy(gifCategories = categories) } - } catch (e: Exception) { - // Ignore failure - } - } - } - - private var searchJob: Job? = null - fun searchGifs(query: String) { - searchJob?.cancel() - if (query.isBlank()) { - _state.update { it.copy(searchedGifs = emptyList()) } - return - } - searchJob = viewModelScope.launch { - delay(500) - _state.update { it.copy(isGifsLoading = true) } - try { - val gifs = repository.searchGifs(query, 0) - _state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) } - } catch (e: Exception) { - _state.update { it.copy(isGifsLoading = false) } - } - } - } - - fun sendGif(url: String) { - val chatId = currentChatId ?: return - val replyToId = _state.value.replyingMessage?.id - _state.update { it.copy(replyingMessage = null) } - - viewModelScope.launch { - try { - val attachment = chats.data.remote.api.AttachmentRequest( - type = "image", - url = url, - fileName = "gif.gif", - fileSize = 0 - ) - repository.sendMessage( - chatId = chatId, - content = null, - type = "image", - attachments = listOf(attachment), - replyToId = replyToId - ) - // Сообщение автоматически появится в UI через Flow из Room - - // Add to recent - val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs - val selectedGif = allGifs.find { gif -> - val gifUrl = gif.files?.get("hd")?.get("gif")?.url - ?: gif.files?.get("sd")?.get("gif")?.url - ?: gif.file?.get("hd")?.get("gif")?.url - ?: gif.file?.get("sd")?.get("gif")?.url - ?: gif.media_formats?.get("gif")?.url - ?: gif.images?.original?.url - gifUrl == url - } - - selectedGif?.let { gif -> - val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20) - _state.update { it.copy(recentGifs = newList) } - } - } catch (e: Exception) { - _state.update { it.copy(error = e.localizedMessage) } - } - } - } -} - diff --git a/client-mobile/chats/presentation/chat_list/ChatListScreen.kt b/client-mobile/chats/presentation/chat_list/ChatListScreen.kt deleted file mode 100644 index ff42cc1..0000000 --- a/client-mobile/chats/presentation/chat_list/ChatListScreen.kt +++ /dev/null @@ -1,127 +0,0 @@ -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.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -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() - - androidx.compose.runtime.LaunchedEffect(Unit) { - viewModel.loadChats() - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White - ) - ) - } - ) { 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 deleted file mode 100644 index 25dbbd6..0000000 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ /dev/null @@ -1,183 +0,0 @@ -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 core.security.TokenManager -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 authRepository: auth.domain.repository.AuthRepository, - private val signalrClient: ChatHubClient, - private val serverConfig: ServerConfig, - private val tokenManager: TokenManager -) : ViewModel() { - - private val _state = MutableStateFlow(ChatListState()) - val state: StateFlow = _state.asStateFlow() - - init { - updatePushToken() - - val isStoriesEnabled = try { - serverConfig.getServerConfig().features.stories - } catch (e: Exception) { - true - } - _state.update { it.copy(isStoriesEnabled = isStoriesEnabled) } - - val token = tokenManager.getToken() - val baseUrl = serverConfig.getBaseUrl() - - // Подключаемся к SignalR только если есть токен И введён URL сервера - if (token != null && baseUrl.isNotBlank()) { - signalrClient.connect(baseUrl.removeSuffix("/api/"), token) - } - - // Single Source of Truth - подписываемся на Flow из Room - observeChatsFromLocal() - - // Запускаем периодическую синхронизацию - viewModelScope.launch { - repository.schedulePeriodicSync() - } - - observeSignalREvents() - } - - /** - * Подписка на локальные чаты из Room (Single Source of Truth) - */ - private fun observeChatsFromLocal() { - repository.getChatsFlow() - .onEach { chats -> - _state.update { currentState -> - currentState.copy( - chats = sortChats(chats), - isLoading = false - ) - } - } - .catch { e -> - android.util.Log.e("ChatListVM", "Error observing chats", e) - _state.update { it.copy(isLoading = false, error = e.message) } - } - .launchIn(viewModelScope) - } - - /** - * Принудительная синхронизация чатов с сервером - */ - fun loadChats() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true) } - try { - repository.syncChats() - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.message) } - } - } - } - - private fun updatePushToken() { - com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - val token = task.result - viewModelScope.launch { - try { - authRepository.updatePushToken(token) - } catch (e: Exception) { - android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}") - } - } - } - } - } - - private fun getCurrentUserId(): String = tokenManager.getUserId() ?: "" - - private fun sortChats(chats: List): List { - return chats.sortedWith(compareByDescending { - it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true) - }.thenByDescending { it.lastMessage?.createdAt }) - } - - private fun observeSignalREvents() { - signalrClient.events - .onEach { event -> - when (event) { - is ChatEvent.NewMessage -> { - updateChatsWithNewMessage(event) - } - is ChatEvent.NewChat -> { - val currentUserId = getCurrentUserId() - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - _state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) } - } - is ChatEvent.MessagesRead -> { - if (event.userId == getCurrentUserId()) { - _state.update { currentState -> - val updatedChats = currentState.chats.map { chat -> - if (chat.id == event.chatId) { - chat.copy(unreadCount = 0) - } else chat - } - currentState.copy(chats = sortChats(updatedChats)) - } - } - } - else -> Unit - } - } - .launchIn(viewModelScope) - } - - private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val currentUserId = getCurrentUserId() - - _state.update { currentState -> - val updatedChats = currentState.chats.map { chat -> - if (chat.id.equals(event.message.chatId, ignoreCase = true)) { - val isMyMessage = event.message.senderId == currentUserId - val isAlreadySeen = chat.lastMessage?.id == event.message.id - - val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl) - val newCount = if (isMyMessage || isAlreadySeen) { - chat.unreadCount - } else { - chat.unreadCount + 1 - } - - if (!isAlreadySeen) { - android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount") - } - - chat.copy( - lastMessage = lastMsgDomain, - unreadCount = newCount - ) - } else chat - } - - currentState.copy(chats = sortChats(updatedChats)) - } - } -} diff --git a/client-mobile/chats/presentation/components/ChatItem.kt b/client-mobile/chats/presentation/components/ChatItem.kt deleted file mode 100644 index 1bfdaee..0000000 --- a/client-mobile/chats/presentation/components/ChatItem.kt +++ /dev/null @@ -1,124 +0,0 @@ -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.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 chats.domain.model.Chat - -import androidx.compose.foundation.shape.RoundedCornerShape -import core.presentation.components.AppAvatar -import chats.domain.model.MediaType -import androidx.compose.runtime.remember - -@Composable -fun ChatItem( - chat: Chat, - onClick: (String) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick(chat.id) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppAvatar( - url = chat.avatar, - name = chat.name, - size = 50.dp - ) - - 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 { - val formattedTime = remember(it.createdAt) { - try { - it.createdAt.substringAfter('T').take(5) - } catch (e: Exception) { - "" - } - } - Text( - text = formattedTime, - style = MaterialTheme.typography.labelSmall, - color = Color.Gray - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val previewText = remember(chat.lastMessage) { - val msg = chat.lastMessage - if (msg == null) return@remember "No messages yet" - - if (!msg.content.isNullOrBlank()) { - msg.content - } else if (msg.mediaType == MediaType.AUDIO) { - "Голосовое сообщение" - } else if (msg.media.isNotEmpty()) { - when (msg.mediaType) { - MediaType.IMAGE -> "Фото" - MediaType.VIDEO -> "Видео" - MediaType.FILE -> "Файл" - else -> "Медиа" - } - } else { - "Сообщение" - } - } - Text( - text = previewText, - 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 deleted file mode 100644 index 9ba6823..0000000 --- a/client-mobile/chats/presentation/components/EmojiPicker.kt +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 5a47959..0000000 --- a/client-mobile/chats/presentation/components/KlipyPicker.kt +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 22c444e..0000000 --- a/client-mobile/chats/presentation/components/LinkPreview.kt +++ /dev/null @@ -1,181 +0,0 @@ -package chats.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -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.* -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.platform.LocalContext -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 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -private val linkMetadataCache = mutableMapOf() - -@Composable -fun LinkPreview( - url: String, - modifier: Modifier = Modifier, - contentColor: Color = Color.White -) { - var metadata by remember(url) { mutableStateOf(linkMetadataCache[url]) } - var loading by remember(url) { mutableStateOf(metadata == null) } - val context = LocalContext.current - - LaunchedEffect(url) { - if (metadata != null) { - loading = false - return@LaunchedEffect - } - - loading = true - try { - val client = okhttp3.OkHttpClient.Builder() - .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .build() - - val request = okhttp3.Request.Builder() - .url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}") - .header("User-Agent", "KnotMessenger/1.0 (Android)") - .build() - - withContext(Dispatchers.IO) { - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val body = response.body?.string() - if (body != null) { - val json = com.google.gson.JsonParser.parseString(body).asJsonObject - if (json.has("status") && json.get("status").asString == "success") { - val data = json.getAsJsonObject("data") - - val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString - val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString - val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString - - if (title != null || description != null || imageUrl != null) { - metadata = LinkMetadata( - url = url, - title = title, - description = description, - imageUrl = imageUrl - ) - metadata?.let { linkMetadataCache[url] = it } - } - Unit - } - Unit - } - Unit - } else { - android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}") - } - } - Unit - } - } catch (e: Exception) { - android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e) - } finally { - loading = false - } - } - - if (loading) { - Text( - text = "Загрузка предпросмотра...", - style = MaterialTheme.typography.labelSmall, - color = contentColor.copy(alpha = 0.5f), - modifier = modifier.padding(vertical = 4.dp), - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - ) - return - } - - val currentMetadata = metadata ?: LinkMetadata(url = url) - - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(Color.Black.copy(alpha = 0.2f)) - .clickable { - try { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) - context.startActivity(intent) - } catch (e: Exception) {} - } - .border( - width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp, - color = Color.White.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) - ) { - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(3.dp) - .background(Color(0xFF3096E5)) - ) - - Column(modifier = Modifier.padding(10.dp)) { - val domain = remember(url) { - try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" } - } - - Text( - text = domain, - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF3096E5), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 2.dp) - ) - - Text( - text = currentMetadata.title ?: url, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = contentColor, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - - currentMetadata.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = contentColor.copy(alpha = 0.7f), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - lineHeight = 16.sp - ) - } - } - } - - currentMetadata.imageUrl?.let { - AsyncImage( - model = it, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)), - contentScale = ContentScale.Crop - ) - } - } -} diff --git a/client-mobile/chats/presentation/components/MediaPicker.kt b/client-mobile/chats/presentation/components/MediaPicker.kt deleted file mode 100644 index 7fadf33..0000000 --- a/client-mobile/chats/presentation/components/MediaPicker.kt +++ /dev/null @@ -1,416 +0,0 @@ -package chats.presentation.components - -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -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 coil.compose.AsyncImage -import chats.data.remote.api.KlipyGifDto -import core.utils.CoilUtils -import kotlinx.coroutines.launch - -enum class MediaTab { EMOJI, GIF } - -@Composable -fun MediaPicker( - trendingGifs: List, - searchedGifs: List, - recentGifs: List = emptyList(), - gifCategories: List = emptyList(), - isGifsLoading: Boolean, - error: String?, - onEmojiSelected: (String) -> Unit, - onGifSelected: (String) -> Unit, - onGifSearch: (String) -> Unit, - modifier: Modifier = Modifier -) { - var activeTab by remember { mutableStateOf(MediaTab.EMOJI) } - - // Вычисляем индексы для прыжков (учитываем заголовки) - val emojiCategories = remember { - listOf( - EmojiCategory("Недавние", Icons.Rounded.Schedule, listOf("👍", "❤️", "😂", "😍", "🙏", "🔥", "😭", "😊")), - EmojiCategory("Смайлы", Icons.Rounded.EmojiEmotions, listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇")), - EmojiCategory("Животные", Icons.Rounded.Pets, listOf("🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷")), - EmojiCategory("Еда", Icons.Rounded.Fastfood, listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭")), - EmojiCategory("Спорт", Icons.Rounded.SportsFootball, listOf("⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🥅", "🏒")), - EmojiCategory("Транспорт", Icons.Rounded.DirectionsCar, listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛")), - EmojiCategory("Объекты", Icons.Rounded.Lightbulb, listOf("💡", "🔦", "🕯️", "🪔", "📔", "📕", "📖", "💚", "💙", "💜", "🤎", "🖤")), - EmojiCategory("Музыка", Icons.Rounded.MusicNote, listOf("🎵", "🎶", "🎼", "🎹", "🎸", "🎻", "🎷", "🎺", "🥁", "🎤", "🎧", "📻")), - EmojiCategory("Флаги", Icons.Rounded.Flag, listOf("🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇱", "🇩🇿", "🇦🇸", "🇦🇩")) - ) - } - - val categoryIndices = remember(emojiCategories) { - var currentIdx = 0 - emojiCategories.map { cat -> - val startIdx = currentIdx - currentIdx += 1 + cat.emojis.size - cat.icon to startIdx - } - } - - Column( - modifier = modifier - .fillMaxWidth() - .height(450.dp) - .background(Color(0xFF1C1C1C), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) - ) { - // Табы (Эмодзи / GIF) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .background(Color(0xFF2C2C2C), RoundedCornerShape(12.dp)) - .padding(2.dp) - ) { - MediaTabItem( - title = "ЭМОДЗИ", - isSelected = activeTab == MediaTab.EMOJI, - onClick = { activeTab = MediaTab.EMOJI }, - modifier = Modifier.weight(1f) - ) - MediaTabItem( - title = "GIF", - isSelected = activeTab == MediaTab.GIF, - onClick = { activeTab = MediaTab.GIF }, - modifier = Modifier.weight(1f) - ) - } - - Crossfade(targetState = activeTab, label = "media_tab") { tab -> - when (tab) { - MediaTab.EMOJI -> EmojiContent( - emojiCategories = emojiCategories, - categoryIndices = categoryIndices, - onEmojiSelected = onEmojiSelected - ) - MediaTab.GIF -> GifContent( - trendingGifs = trendingGifs, - searchedGifs = searchedGifs, - recentGifs = recentGifs, - gifCategories = gifCategories, - isGifsLoading = isGifsLoading, - error = error, - onGifSelected = onGifSelected, - onGifSearch = onGifSearch - ) - } - } - } -} - -@Composable -private fun MediaTabItem( - title: String, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .clip(RoundedCornerShape(10.dp)) - .background(if (isSelected) Color(0xFF3C3C3C) else Color.Transparent) - .clickable { onClick() } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = title, - color = if (isSelected) Color.White else Color.Gray, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold - ) - } -} - -@Composable -private fun EmojiContent( - emojiCategories: List, - categoryIndices: List>, - onEmojiSelected: (String) -> Unit -) { - val scrollState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() - val scope = rememberCoroutineScope() - - Column(modifier = Modifier.fillMaxSize()) { - // Категории - LazyRow( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(categoryIndices) { (icon, index) -> - Icon( - imageVector = icon, - contentDescription = null, - tint = Color.Gray, - modifier = Modifier - .size(24.dp) - .clickable { - scope.launch { - scrollState.animateScrollToItem(index) - } - } - ) - } - } - - // Поиск - TextField( - value = "", - onValueChange = {}, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .clip(RoundedCornerShape(8.dp)), - placeholder = { Text("Поиск", color = Color.Gray) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color(0xFF2C2C2C), - unfocusedContainerColor = Color(0xFF2C2C2C), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - - LazyVerticalGrid( - columns = GridCells.Fixed(8), - state = scrollState, - modifier = Modifier.weight(1f).padding(horizontal = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - emojiCategories.forEach { category -> - // Заголовок категории - item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(8) }) { - Text( - text = category.name, - color = Color.Gray, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(8.dp) - ) - } - - items(category.emojis, key = { emoji -> emoji }) { emoji -> - Box( - modifier = Modifier - .aspectRatio(1f) - .clickable { onEmojiSelected(emoji) }, - contentAlignment = Alignment.Center - ) { - Text(text = emoji, fontSize = 28.sp) - } - } - } - } - } -} - -data class EmojiCategory(val name: String, val icon: ImageVector, val emojis: List) - -@Composable -private fun GifContent( - trendingGifs: List, - searchedGifs: List, - recentGifs: List, - gifCategories: List, - isGifsLoading: Boolean, - error: String?, - onGifSelected: (String) -> Unit, - onGifSearch: (String) -> Unit -) { - var query by remember { mutableStateOf("") } - var subTab by remember { mutableStateOf("trending") } - val context = LocalContext.current - val imageLoader = CoilUtils.getGifImageLoader(context) - - Column(modifier = Modifier.fillMaxSize()) { - TextField( - value = query, - onValueChange = { - query = it - if (it.isNotEmpty()) subTab = "search" - else if (subTab == "search") subTab = "trending" - onGifSearch(it) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .clip(RoundedCornerShape(8.dp)), - placeholder = { Text("Поиск GIF...", color = Color.Gray) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color(0xFF2C2C2C), - unfocusedContainerColor = Color(0xFF2C2C2C), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - - // Под-вкладки GIF (Recent, Trending, Categories) - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Rounded.Schedule, - contentDescription = null, - tint = if (subTab == "recent") Color(0xFF3B82F6) else Color.Gray, - modifier = Modifier.size(20.dp).clickable { subTab = "recent"; query = "" } - ) - Icon( - Icons.Rounded.TrendingUp, - contentDescription = null, - tint = if (subTab == "trending") Color(0xFF3B82F6) else Color.Gray, - modifier = Modifier.size(20.dp).clickable { subTab = "trending"; query = "" } - ) - Icon( - Icons.Rounded.GridView, - contentDescription = null, - tint = if (subTab == "categories") Color(0xFF3B82F6) else Color.Gray, - modifier = Modifier.size(20.dp).clickable { subTab = "categories"; query = "" } - ) - } - - if (subTab == "categories") { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.weight(1f).padding(4.dp), - contentPadding = PaddingValues(4.dp) - ) { - items(gifCategories) { category -> - Box( - modifier = Modifier - .padding(4.dp) - .aspectRatio(1.77f) - .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFF2C2C2C)) - .clickable { - query = category.query - subTab = "search" - onGifSearch(category.query) - } - ) { - AsyncImage( - model = category.preview_url, - contentDescription = category.category, - modifier = Modifier.fillMaxSize().alpha(0.6f), - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = category.category.uppercase(), - color = Color.White, - fontSize = 10.sp, - fontWeight = FontWeight.Black, - letterSpacing = 2.sp - ) - } - } - } - } - } else { - val displayGifs = when (subTab) { - "recent" -> recentGifs - "search" -> searchedGifs - else -> trendingGifs - } - - if (isGifsLoading && displayGifs.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = Color(0xFF3B82F6)) - } - } else if (!isGifsLoading && displayGifs.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(error ?: "Ничего не найдено", color = Color.Gray) - if (error != null) { - Button(onClick = { onGifSearch(query) }) { - Text("Повторить") - } - } - } - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier.weight(1f).padding(4.dp), - contentPadding = PaddingValues(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif -> - val url = gif.files?.get("hd")?.get("gif")?.url - ?: gif.files?.get("sd")?.get("gif")?.url - ?: gif.file?.get("hd")?.get("gif")?.url - ?: gif.file?.get("sd")?.get("gif")?.url - ?: gif.media_formats?.get("gif")?.url - ?: gif.images?.original?.url - - val preview = gif.files?.get("sd")?.get("webp")?.url - ?: gif.files?.get("sd")?.get("gif")?.url - ?: gif.file?.get("sd")?.get("webp")?.url - ?: gif.media_formats?.get("tinygif")?.url - ?: gif.images?.fixed_height_small?.url - ?: url - - if (url != null) { - AsyncImage( - model = preview ?: url, - imageLoader = imageLoader, - contentDescription = gif.title, - modifier = Modifier - .padding(2.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFF2C2C2C)) - .clickable { onGifSelected(url) }, - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier - .padding(2.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFF3C3C3C)), - contentAlignment = Alignment.Center - ) { - Text(gif.title ?: "GIF", color = Color.Gray, fontSize = 10.sp) - } - } - } - } - } - } - } -} diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt deleted file mode 100644 index ccb18cf..0000000 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ /dev/null @@ -1,898 +0,0 @@ -package chats.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.CircularProgressIndicator -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.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.res.painterResource -import ru.knot.messager.R -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 chats.domain.model.Media -import chats.domain.model.MessageStatus -import coil.compose.AsyncImage -import coil.request.ImageRequest -import core.presentation.components.AppVideoPlayer -import core.presentation.components.AppAudioPlayer -import core.presentation.components.AppAvatar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import chats.presentation.components.LinkPreview -import android.content.Intent -import android.net.Uri -import androidx.compose.ui.platform.LocalContext -import androidx.compose.material3.* -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.ui.input.pointer.pointerInput -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import core.presentation.components.AppMediaLightbox - -@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) -@Composable -fun MessageBubble( - message: Message, - isCurrentUser: Boolean, - onReactionClick: (String) -> Unit = {}, - autoPlay: Boolean = false, - initialPlaybackSpeed: Float = 1.0f, - onVoiceFinished: (Float) -> Unit = {}, - onReply: (Message) -> Unit = {}, - onReplyClick: (Message) -> Unit = {}, - onSelect: (String) -> Unit = {}, - onForward: (Message) -> Unit = {}, - onPin: (Message) -> Unit = {}, - onEdit: (Message) -> Unit = {}, - onDelete: (Message, Boolean) -> Unit = { _, _ -> }, - isSelected: Boolean = false, - isSelectionMode: Boolean = false, - onMediaClick: (chats.domain.model.Media) -> Unit = {} -) { - val context = LocalContext.current - var showReactionPicker by remember { mutableStateOf(false) } - var lightboxMediaIndex by remember { mutableStateOf(null) } - - val contentColor = Color.White - var showContextMenu by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(false) } - - val backgroundColor = when { - isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f) - showContextMenu -> Color(0xFF007AFF) // Even brighter highlight - isCurrentUser -> Color(0xFF3096E5) - else -> Color(0xFF212121) - } - - 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.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty())) - - Row( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { // Clicks anywhere in the Row row now work - detectTapGestures( - onTap = { - if (isSelectionMode) { - onSelect(message.id) - } else { - showContextMenu = !showContextMenu - } - }, - onLongPress = { - if (!isSelectionMode) { - onSelect(message.id) - } - } - ) - } - .padding(horizontal = 8.dp, vertical = 2.dp), - horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, - verticalAlignment = Alignment.Bottom - ) { - var bubbleHeight by remember { mutableIntStateOf(0) } - var isNearBottom by remember { mutableStateOf(false) } - - Box( - modifier = Modifier - .widthIn(max = 300.dp) - .onGloballyPositioned { coords -> - bubbleHeight = coords.size.height - val screenHeight = coords.parentLayoutCoordinates?.size?.height ?: 2000 - val yInWindow = coords.localToWindow(Offset.Zero).y - // If bottom of message is in the last 40% of screen, open menu UP - isNearBottom = yInWindow > screenHeight * 0.6f - }, - contentAlignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart - ) { - // Actual bubble - Column( - modifier = Modifier - .clip(shape) - .background(backgroundColor) - .padding(8.dp) - ) { - if (showContextMenu) { - val density = androidx.compose.ui.platform.LocalDensity.current - val menuWidth = 220.dp - val menuWidthPx = with(density) { menuWidth.toPx() } - - Popup( - alignment = if (isNearBottom) { - if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd - } else { - if (isCurrentUser) Alignment.TopStart else Alignment.TopEnd - }, - offset = if (isCurrentUser) { - // Message is on RIGHT, show menu to its LEFT - IntOffset(-menuWidthPx.toInt() - 20, 0) - } else { - // Message is on LEFT, show menu to its RIGHT - IntOffset(menuWidthPx.toInt() + 20, 0) - }, - onDismissRequest = { showContextMenu = false }, - properties = androidx.compose.ui.window.PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) - ) { - MessageContextMenu( - isMyMessage = isCurrentUser, - isPinned = message.isPinned, - onReactionSelected = { - onReactionClick(it) - showContextMenu = false - }, - onAction = { action -> - showContextMenu = false - when(action) { - "reply" -> onReply(message) - "select" -> onSelect(message.id) - "forward" -> onForward(message) - "pin" -> onPin(message) - "edit" -> onEdit(message) - "copy" -> { - val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - val clip = android.content.ClipData.newPlainText("message", message.content) - clipboard.setPrimaryClip(clip) - } - "delete" -> { - showDeleteDialog = true - } - } - } - ) - } - } - - // Forwarded Info - if (message.isForwarded) { - Text( - text = "Forwarded from ${message.forwardedFromName ?: "Unknown"}", - style = MaterialTheme.typography.labelSmall, - color = contentColor.copy(alpha = 0.7f), - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, - modifier = Modifier.padding(bottom = 4.dp) - ) - } - - // Reply Info - message.replyTo?.let { reply -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 6.dp) - .clip(RoundedCornerShape(4.dp)) - .background(contentColor.copy(alpha = 0.1f)) - .height(IntrinsicSize.Min) - .clickable { onReplyClick(reply) } - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(2.dp) - .background(if (isCurrentUser) Color.White else Color(0xFF3096E5)) - ) - val context = androidx.compose.ui.platform.LocalContext.current - val isReplyImage = reply.mediaType == MediaType.IMAGE || reply.mediaType == MediaType.VIDEO || reply.mediaType == MediaType.GIF - - if (isReplyImage && reply.media.isNotEmpty()) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(reply.media.first().url) - .crossfade(true) - .build(), - contentDescription = null, - modifier = Modifier - .fillMaxHeight() - .width(40.dp) - .clip(RoundedCornerShape(0.dp, 4.dp, 4.dp, 0.dp)), - contentScale = ContentScale.Crop - ) - } - - Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) { - val accentColor = if (isCurrentUser) Color.White else Color(0xFF3096E5) - Text( - text = reply.senderName, - style = MaterialTheme.typography.labelMedium, - color = accentColor, - maxLines = 1, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold - ) - - val photoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_photo) - val videoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_video) - val voiceText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.voice_message) - val audioText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_audio) - val fileText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_file) - val gifText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_gif) - val mediaText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.media) - - val replyText = remember(reply) { - if (!reply.content.isNullOrEmpty()) { - reply.content - } else { - when (reply.mediaType) { - MediaType.IMAGE -> photoText - MediaType.VIDEO -> videoText - MediaType.AUDIO -> if (reply.media.any { it.type == "voice" }) voiceText else audioText - MediaType.FILE -> fileText - MediaType.GIF -> gifText - else -> mediaText - } - } - } - - Text( - text = replyText, - style = MaterialTheme.typography.bodySmall, - color = contentColor.copy(alpha = 0.8f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - // GIF Content (Klipy) - if (message.mediaType == MediaType.GIF) { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable { /* Handle click */ } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(message.content) - .crossfade(true) - .build(), - imageLoader = core.utils.CoilUtils.getGifImageLoader(context), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - Spacer(modifier = Modifier.height(4.dp)) - } - - // Loading state for temp media messages - if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(8.dp)) - .background(contentColor.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = contentColor.copy(alpha = 0.5f) - ) - } - Spacer(modifier = Modifier.height(4.dp)) - } - - // Media Content (Attachments) - if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) { - val mediaCount = message.media.size - if (mediaCount == 1) { - val mediaItem = message.media[0] - when { - mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable { onMediaClick(mediaItem) } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(mediaItem.url) - .crossfade(true) - .memoryCacheKey(mediaItem.url) - .build(), - imageLoader = core.utils.CoilUtils.getGifImageLoader(context), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - } - message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> { - Box( - modifier = Modifier - .fillMaxWidth() - .height(240.dp) - .clip(RoundedCornerShape(8.dp)) - ) { - AppVideoPlayer( - url = mediaItem.url, - modifier = Modifier.fillMaxSize(), - useController = false, - isMuted = true, - autoPlay = true - ) - // Прозрачный слой поверх видео для клика - Box(modifier = Modifier - .fillMaxSize() - .background(Color.Transparent) - .clickable { onMediaClick(mediaItem) } - ) - - Icon( - imageVector = Icons.Default.VolumeOff, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(20.dp) - ) - } - } - mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> { - AppAudioPlayer( - url = mediaItem.url, - name = mediaItem.filename, - size = mediaItem.size, - isVoiceMessage = isVoiceMessage, - initialPlaybackSpeed = initialPlaybackSpeed, - autoPlay = autoPlay, - onFinished = onVoiceFinished, - contentColor = contentColor - ) - } - else -> { - FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor) - } - } - } else { - // Multi-media grid - PhotoGrid( - mediaList = message.media, - isCurrentUser = isCurrentUser, - onMediaClick = { index -> onMediaClick(message.media[index]) }, - modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp)) - ) - } - Spacer(modifier = Modifier.height(4.dp)) - } - - // Text Content - if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) { - Column { - val annotatedString = remember(message.content) { - val text = message.content ?: "" - val links = core.utils.LinkParser.findLinks(text) - androidx.compose.ui.text.buildAnnotatedString { - append(text) - links.forEach { link -> - val startIndex = text.indexOf(link) - if (startIndex >= 0) { - val endIndex = startIndex + link.length - addStyle( - style = androidx.compose.ui.text.SpanStyle( - color = Color(0xFF3096E5), - textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline - ), - start = startIndex, - end = endIndex - ) - addStringAnnotation( - tag = "URL", - annotation = link, - start = startIndex, - end = endIndex - ) - } - } - } - } - - val context = androidx.compose.ui.platform.LocalContext.current - androidx.compose.foundation.text.ClickableText( - text = annotatedString, - style = MaterialTheme.typography.bodyMedium.copy(color = contentColor), - onClick = { offset -> - annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - try { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item)) - context.startActivity(intent) - } catch (e: Exception) {} - } - } - ) - - val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) } - if (links.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - LinkPreview( - url = links[0], - contentColor = contentColor - ) - } - } - } - - // Time and Status - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - if (message.reactions.isNotEmpty()) { - MessageReactions( - reactions = message.reactions, - onReactionClick = onReactionClick, - modifier = Modifier.padding(end = 4.dp) - ) - } - val formattedTime = remember(message.createdAt) { - try { - val instant = Instant.parse(message.createdAt) - val zonedDateTime = instant.atZone(ZoneId.systemDefault()) - zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm")) - } catch (e: Exception) { - message.createdAt.substringAfter('T').take(5) - } - } - Text( - text = formattedTime, - style = MaterialTheme.typography.labelSmall, - color = contentColor.copy(alpha = 0.6f), - fontSize = 11.sp - ) - if (message.isPinned) { - Icon( - imageVector = Icons.Default.PushPin, - contentDescription = "Pinned", - modifier = Modifier.size(12.dp).padding(start = 2.dp).graphicsLayer { rotationZ = 45f }, - tint = contentColor.copy(alpha = 0.6f) - ) - } - if (isCurrentUser) { - Spacer(modifier = Modifier.width(2.dp)) - // Отображение статуса отправки сообщения - when (message.status) { - MessageStatus.PENDING -> { - // Часы - ожидает отправки - Icon( - imageVector = Icons.Default.Schedule, - contentDescription = "Pending", - modifier = Modifier.size(12.dp), - tint = contentColor.copy(alpha = 0.6f) - ) - } - MessageStatus.SENDING -> { - // Круговой индикатор загрузки - CircularProgressIndicator( - modifier = Modifier.size(10.dp), - strokeWidth = 1.5.dp, - color = contentColor.copy(alpha = 0.6f) - ) - } - MessageStatus.FAILED -> { - // Красный крестик - ошибка отправки - Icon( - imageVector = Icons.Default.Error, - contentDescription = "Failed", - modifier = Modifier.size(12.dp), - tint = Color(0xFFFF5252) - ) - } - else -> { - // Одна галочка для SENT, две для DELIVERED/READ - Icon( - imageVector = if (message.status == MessageStatus.READ) Icons.Default.DoneAll else Icons.Default.Done, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = if (message.status == MessageStatus.READ) Color(0xFF4CAF50) else contentColor.copy(alpha = 0.6f) - ) - } - } - } - } - } // End of bubble Column - - if (isSelected) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = Color.White, - modifier = Modifier - .align(Alignment.Center) - .size(32.dp) - .background(Color(0xFF3096E5), CircleShape) - .padding(2.dp) - ) - } - } // End of Box -} // End of Row - - lightboxMediaIndex?.let { index -> - AppMediaLightbox( - mediaList = message.media, - initialIndex = index, - onClose = { lightboxMediaIndex = null } - ) - } - - if (showDeleteDialog) { - var deleteForEveryone by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text("Удалить сообщение?") }, - text = { - Column { - Text("Вы уверены, что хотите удалить это сообщение?") - if (isCurrentUser) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 8.dp).clickable { deleteForEveryone = !deleteForEveryone } - ) { - Checkbox( - checked = deleteForEveryone, - onCheckedChange = { deleteForEveryone = it } - ) - Text("Удалить у всех") - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - onDelete(message, deleteForEveryone) - showDeleteDialog = false - }, - colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935)) - ) { - Text("Удалить") - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text("Отмена") - } - } - ) - } -} - -@Composable -fun MessageReactions( - reactions: Map, - onReactionClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - reactions.forEach { (emoji, count) -> - Box( - modifier = Modifier - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.2f)) - .clickable { onReactionClick(emoji) } - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = emoji, fontSize = 12.sp) - if (count > 1) { - Spacer(modifier = Modifier.width(2.dp)) - Text(text = count.toString(), fontSize = 10.sp, color = Color.White) - } - } - } - } - } -} - -@Composable -fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(contentColor.copy(alpha = 0.1f)) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Default.Description, contentDescription = null, tint = contentColor) - } - Column( - modifier = Modifier.weight(1f).padding(horizontal = 8.dp) - ) { - val fileName = media.filename ?: "File" - Text( - text = fileName, - style = MaterialTheme.typography.bodySmall, - color = contentColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - val sizeStr = media.size?.let { - if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB" - } ?: "File" - Text( - text = sizeStr, - style = MaterialTheme.typography.labelSmall, - color = contentColor.copy(alpha = 0.6f) - ) - } - Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp)) - } -} - -@Composable -fun PhotoGrid(mediaList: List, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) { - if (mediaList.isEmpty()) return - - val columns = when { - mediaList.size == 1 -> 1 - mediaList.size <= 4 -> 2 - else -> 3 - } - - Column(modifier = modifier) { - val rows = (mediaList.size + columns - 1) / columns - for (i in 0 until rows) { - Row(modifier = Modifier.weight(1f).fillMaxWidth()) { - for (j in 0 until columns) { - val index = i * columns + j - if (index < mediaList.size) { - GridItem( - media = mediaList[index], - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(1.dp) - .clickable { onMediaClick(index) } - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - } - } -} - -@Composable -fun GridItem(media: Media, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isImage = media.type.startsWith("image") - val isVideo = media.type.startsWith("video") - - Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) { - if (isImage || isVideo) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(media.url) - .crossfade(true) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - if (isVideo) { - Icon( - imageVector = Icons.Default.PlayCircle, - contentDescription = null, - tint = Color.White.copy(alpha = 0.8f), - modifier = Modifier.align(Alignment.Center).size(32.dp) - ) - } - } else { - Column( - modifier = Modifier.fillMaxSize().padding(4.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.InsertDriveFile, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(32.dp) - ) - Text( - text = media.filename ?: "File", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.9f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Text( - text = media.url.substringAfterLast(".").uppercase(), - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.5f), - fontSize = 8.sp - ) - } - } - } -} - -@Composable -fun MessageContextMenu( - isMyMessage: Boolean, - isPinned: Boolean, - onReactionSelected: (String) -> Unit, - onAction: (String) -> Unit -) { - var isVisible by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - isVisible = true - } - - androidx.compose.animation.AnimatedVisibility( - visible = isVisible, - enter = androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(200)) + - androidx.compose.animation.scaleIn( - initialScale = 0.5f, - animationSpec = androidx.compose.animation.core.spring( - dampingRatio = 0.6f, - stiffness = 400f - ), - transformOrigin = androidx.compose.ui.graphics.TransformOrigin( - if (isVisible) 0.5f else 0.5f, - 0f - ) - ), - exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut() - ) { - androidx.compose.material3.Surface( - modifier = Modifier - .width(280.dp) // Even wider for all reactions - .clip(RoundedCornerShape(20.dp)), - color = Color(0xFF1E1E1E), - shadowElevation = 15.dp, - border = androidx.compose.foundation.BorderStroke(0.5.dp, Color.White.copy(alpha = 0.15f)) - ) { - Column { - // Extended Reactions scrollable bar - androidx.compose.foundation.lazy.LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - contentPadding = PaddingValues(horizontal = 12.dp) - ) { - val telegramReactions = listOf( - "👍", "❤️", "😂", "🔥", "💯", "👏", "🤩", "🤔", "🤯", "😱", "🤬", - "👎", "💩", "🤡", "🥳", "😇", "🌚", "🙏", "👌", "🕊️", "🐳", - "🌭", "🦄", "🍌", "💊", "🍓", "🍾", "💋", "🖕", "👀", "👻", - "🤝", "🤣", "⚡", "✨", "🎈", "🎉", "🧊", "🆒", "🤨", "😐", - "🤷‍♂️", "💅", "🏆", "👾", "🎯", "🎲", "🍷", "🤮", "🥱", "😴", - "🤐", "🥴" - ) - items(telegramReactions) { emoji -> - Text( - text = emoji, - fontSize = 28.sp, - modifier = Modifier - .clip(CircleShape) - .clickable { onReactionSelected(emoji) } - .padding(4.dp) - ) - } - } - - Divider(color = Color.White.copy(alpha = 0.1f)) - - ContextMenuItem(Icons.Default.Reply, "Ответить", onClick = { onAction("reply") }) - ContextMenuItem(Icons.Default.CheckCircle, "Выбрать", onClick = { onAction("select") }) - ContextMenuItem(Icons.Default.Forward, "Переслать", onClick = { onAction("forward") }) - ContextMenuItem( - icon = Icons.Default.PushPin, - text = if (isPinned) "Открепить" else "Закрепить", - onClick = { onAction("pin") } - ) - ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") }) - if (isMyMessage) { - ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") }) - } - - Divider(color = Color.White.copy(alpha = 0.1f)) - - ContextMenuItem( - Icons.Default.Delete, - "Удалить", - textColor = Color(0xFFE53935), - onClick = { onAction("delete") } - ) - } - } -} -} - -@Composable -fun ContextMenuItem( - icon: ImageVector, - text: String, - textColor: Color = Color.White, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = textColor.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = text, - color = textColor, - style = MaterialTheme.typography.bodyMedium - ) - } -} diff --git a/client-mobile/chats/presentation/components/MessageReactions.kt b/client-mobile/chats/presentation/components/MessageReactions.kt deleted file mode 100644 index f0c2308..0000000 --- a/client-mobile/chats/presentation/components/MessageReactions.kt +++ /dev/null @@ -1,61 +0,0 @@ -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/chats/presentation/components/SwipeableMessageItem.kt b/client-mobile/chats/presentation/components/SwipeableMessageItem.kt deleted file mode 100644 index 09d34e7..0000000 --- a/client-mobile/chats/presentation/components/SwipeableMessageItem.kt +++ /dev/null @@ -1,87 +0,0 @@ -package chats.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Reply -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -@Composable -fun SwipeableMessageItem( - onReply: () -> Unit, - content: @Composable () -> Unit -) { - val density = LocalDensity.current - val scope = rememberCoroutineScope() - val replyThreshold = with(density) { 60.dp.toPx() } - val maxDrag = with(density) { 90.dp.toPx() } - - var offsetX by remember { mutableFloatStateOf(0f) } - var isTriggered by remember { mutableStateOf(false) } - - Box( - modifier = Modifier - .fillMaxWidth() - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - // We only allow swiping to the left (negative delta) - val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f) - offsetX = newOffset - - isTriggered = newOffset <= -replyThreshold - }, - onDragStopped = { - if (offsetX <= -replyThreshold) { - onReply() - } - offsetX = 0f - isTriggered = false - } - ) - ) { - // Reply Icon (Underneath) - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 16.dp) - .size(36.dp) - .alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f)) - .clip(CircleShape) - .background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Reply, - contentDescription = "Reply", - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - } - - // Message Content - Box( - modifier = Modifier - .offset { IntOffset(offsetX.roundToInt(), 0) } - .fillMaxWidth() - ) { - content() - } - } -} diff --git a/client-mobile/contacts/data/remote/api/ContactApi.kt b/client-mobile/contacts/data/remote/api/ContactApi.kt deleted file mode 100644 index 7141c0a..0000000 --- a/client-mobile/contacts/data/remote/api/ContactApi.kt +++ /dev/null @@ -1,62 +0,0 @@ -package contacts.data.remote.api - -import retrofit2.http.* - -interface ContactApi { - @GET("contacts") - suspend fun getContacts(): List - - @POST("contacts/request") - suspend fun addContact(@Body request: AddContactRequest): Any - - @DELETE("contacts/{userId}") - suspend fun removeContact(@Path("userId") userId: String) - - @GET("profiles/search") - suspend fun searchUsers(@Query("q") query: String): List - - @GET("contacts/requests") - suspend fun getFriendRequests(): List - - @POST("contacts/{friendshipId}/accept") - suspend fun acceptRequest(@Path("friendshipId") friendshipId: String): Any - - @POST("contacts/{friendshipId}/decline") - suspend fun declineRequest(@Path("friendshipId") friendshipId: String): Any -} - -data class FriendRequestDto( - val id: String, - val user: ContactDto, - val createdAt: String, - val isOutgoing: Boolean -) - -data class ContactDto( - @com.google.gson.annotations.SerializedName("id") - val id: String, - @com.google.gson.annotations.SerializedName("userName") - val userName: String?, - @com.google.gson.annotations.SerializedName("username") - val username: String?, - @com.google.gson.annotations.SerializedName("displayName") - val displayName: String?, - @com.google.gson.annotations.SerializedName("avatarUrl") - val avatarUrl: String?, - @com.google.gson.annotations.SerializedName("avatar") - val avatar: String?, - @com.google.gson.annotations.SerializedName("isOnline") - val isOnline: Boolean, - @com.google.gson.annotations.SerializedName("lastSeen") - val lastSeen: String?, - @com.google.gson.annotations.SerializedName("friendshipId") - val friendshipId: String? = null -) { - val effectiveUsername: String get() = username ?: userName ?: "unknown" - val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar -} - -data class AddContactRequest( - @com.google.gson.annotations.SerializedName("contactId") - val contactId: String -) diff --git a/client-mobile/contacts/data/repository/ContactRepositoryImpl.kt b/client-mobile/contacts/data/repository/ContactRepositoryImpl.kt deleted file mode 100644 index 550a37f..0000000 --- a/client-mobile/contacts/data/repository/ContactRepositoryImpl.kt +++ /dev/null @@ -1,74 +0,0 @@ -package contacts.data.repository - -import contacts.data.remote.api.ContactApi -import contacts.data.remote.api.ContactDto -import contacts.domain.repository.ContactRepository -import javax.inject.Inject - -class ContactRepositoryImpl @Inject constructor( - private val api: ContactApi -) : ContactRepository { - - override suspend fun getContacts(): Result> { - return try { - Result.success(api.getContacts()) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun addContact(userId: String): Result { - return try { - val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId)) - // Бэкенд возвращает статус или пустой ответ для запроса, - // так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или - // по-хорошему надо обновить доменную модель, но для начала вернем заглушку. - Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null)) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun removeContact(userId: String): Result { - return try { - api.removeContact(userId) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun searchUsers(query: String): Result> { - return try { - Result.success(api.searchUsers(query)) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun getFriendRequests(): Result> { - return try { - Result.success(api.getFriendRequests()) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun acceptRequest(friendshipId: String): Result { - return try { - api.acceptRequest(friendshipId) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun declineRequest(friendshipId: String): Result { - return try { - api.declineRequest(friendshipId) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } -} diff --git a/client-mobile/contacts/di/ContactModule.kt b/client-mobile/contacts/di/ContactModule.kt deleted file mode 100644 index f33a7ae..0000000 --- a/client-mobile/contacts/di/ContactModule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package contacts.di - -import contacts.data.remote.api.ContactApi -import contacts.data.repository.ContactRepositoryImpl -import contacts.domain.repository.ContactRepository -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 ContactModule { - - @Provides - @Singleton - fun provideContactApi(retrofit: Retrofit): ContactApi { - return retrofit.create(ContactApi::class.java) - } - - @Provides - @Singleton - fun provideContactRepository( - api: ContactApi - ): ContactRepository { - return ContactRepositoryImpl(api) - } -} diff --git a/client-mobile/contacts/domain/repository/ContactRepository.kt b/client-mobile/contacts/domain/repository/ContactRepository.kt deleted file mode 100644 index 8515b67..0000000 --- a/client-mobile/contacts/domain/repository/ContactRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package contacts.domain.repository - -import contacts.data.remote.api.ContactDto - -interface ContactRepository { - suspend fun getContacts(): Result> - suspend fun addContact(userId: String): Result - suspend fun removeContact(userId: String): Result - suspend fun searchUsers(query: String): Result> - suspend fun getFriendRequests(): Result> - suspend fun acceptRequest(friendshipId: String): Result - suspend fun declineRequest(friendshipId: String): Result -} diff --git a/client-mobile/contacts/presentation/ContactListScreen.kt b/client-mobile/contacts/presentation/ContactListScreen.kt deleted file mode 100644 index 8cec1c7..0000000 --- a/client-mobile/contacts/presentation/ContactListScreen.kt +++ /dev/null @@ -1,253 +0,0 @@ -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.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.Chat -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Cancel -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.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, - requests: List = emptyList(), - isLoading: Boolean = false, - onContactClick: (String) -> Unit, - onSearchChange: (String) -> Unit, - onAddContact: (String) -> Unit, - onStartChat: (String) -> Unit, - onAcceptRequest: (String) -> Unit, - onDeclineRequest: (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), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White - ) - ) - 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 = if (searchQuery.isNotEmpty()) { - contacts - } else { - contacts.filter { - if (selectedTab == 1) it.isOnline else true - } - } - - LazyColumn(modifier = Modifier.fillMaxSize()) { - // Contact Requests Section - if (searchQuery.isEmpty() && requests.isNotEmpty()) { - item { - Text( - text = "Заявки в контакты", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - } - items(requests) { request -> - ContactRequestItem( - request = request, - onAccept = { onAcceptRequest(request.id) }, - onDecline = { onDeclineRequest(request.id) } - ) - } - item { - Divider(modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) - } - } - - if (filteredContacts.isEmpty() && requests.isEmpty()) { - item { - Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = if (searchQuery.isNotEmpty()) "Пользователи не найдены" else "Список контактов пуст", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } else { - items(filteredContacts) { contact -> - ContactItem( - contact = contact, - onClick = { onContactClick(contact.id) }, - onAddClick = { onAddContact(contact.id) }, - onChatClick = { onStartChat(contact.id) }, - isSearchMode = searchQuery.isNotEmpty() - ) - } - } - } - } - } - } -} - -@Composable -fun ContactRequestItem( - request: contacts.data.remote.api.FriendRequestDto, - onAccept: () -> Unit, - onDecline: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppAvatar( - url = request.user.effectiveAvatarUrl, - name = request.user.effectiveUsername, - size = 48.dp - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = request.user.displayName ?: request.user.effectiveUsername, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = if (request.isOutgoing) "Исходящий запрос" else "Входящий запрос", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Row { - if (!request.isOutgoing) { - IconButton(onClick = onAccept) { - Icon(Icons.Default.CheckCircle, contentDescription = "Accept", tint = Color(0xFF4CAF50)) - } - } - IconButton(onClick = onDecline) { - Icon(Icons.Default.Cancel, contentDescription = "Decline", tint = MaterialTheme.colorScheme.error) - } - } - } -} - -@Composable -fun ContactItem( - contact: contacts.data.remote.api.ContactDto, - onClick: () -> Unit, - onAddClick: () -> Unit, - onChatClick: () -> Unit, - isSearchMode: Boolean -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(16.dp, 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppAvatar( - url = contact.effectiveAvatarUrl, - name = contact.effectiveUsername, - size = 56.dp - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = contact.displayName ?: contact.effectiveUsername, - 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 - ) - } - - Row { - if (isSearchMode) { - IconButton(onClick = onAddClick) { - Icon( - imageVector = Icons.Default.PersonAdd, - contentDescription = "Add Contact", - tint = MaterialTheme.colorScheme.primary - ) - } - } - IconButton(onClick = onChatClick) { - Icon( - imageVector = Icons.Default.Chat, - contentDescription = "Start Chat", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } -} diff --git a/client-mobile/contacts/presentation/ContactListViewModel.kt b/client-mobile/contacts/presentation/ContactListViewModel.kt deleted file mode 100644 index 261745a..0000000 --- a/client-mobile/contacts/presentation/ContactListViewModel.kt +++ /dev/null @@ -1,122 +0,0 @@ -package contacts.presentation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import chats.domain.repository.ChatRepository -import contacts.domain.repository.ContactRepository -import core.utils.NavigationManager -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 ContactListState( - val contacts: List = emptyList(), - val requests: List = emptyList(), - val isLoading: Boolean = false, - val error: String? = null -) - -@HiltViewModel -class ContactListViewModel @Inject constructor( - private val contactRepository: ContactRepository, - private val chatRepository: ChatRepository, - private val navigationManager: NavigationManager -) : ViewModel() { - private val _state = MutableStateFlow(ContactListState()) - val state: StateFlow = _state.asStateFlow() - - init { - loadData() - } - - fun loadData() { - loadContacts() - loadRequests() - } - - fun loadContacts() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - contactRepository.getContacts() - .onSuccess { contacts -> - _state.update { it.copy(contacts = contacts, isLoading = false) } - } - .onFailure { e -> - _state.update { it.copy(isLoading = false, error = e.message) } - } - } - } - - fun loadRequests() { - viewModelScope.launch { - contactRepository.getFriendRequests() - .onSuccess { requests -> - _state.update { it.copy(requests = requests) } - } - } - } - - fun onSearchChange(query: String) { - if (query.length < 3) { - if (query.isEmpty()) loadData() - return - } - - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - contactRepository.searchUsers(query) - .onSuccess { users -> - _state.update { it.copy(contacts = users, isLoading = false) } - } - .onFailure { e -> - _state.update { it.copy(isLoading = false, error = e.message) } - } - } - } - - fun addContact(userId: String) { - viewModelScope.launch { - contactRepository.addContact(userId) - .onSuccess { - loadData() - } - .onFailure { e -> - _state.update { it.copy(error = e.message) } - } - } - } - - fun acceptRequest(requestId: String) { - viewModelScope.launch { - contactRepository.acceptRequest(requestId) - .onSuccess { loadData() } - .onFailure { e -> _state.update { it.copy(error = e.message) } } - } - } - - fun declineRequest(requestId: String) { - viewModelScope.launch { - contactRepository.declineRequest(requestId) - .onSuccess { loadData() } - .onFailure { e -> _state.update { it.copy(error = e.message) } } - } - } - - fun startChat(userId: String) { - viewModelScope.launch { - try { - // В вебе мы ищем существующий чат или создаем новый. - // В мобилке мы для начала можем просто вызвать createPersonalChat. - // Бэкенд обычно возвращает существующий чат, если он уже есть. - val chat = chatRepository.createPersonalChat(userId) - navigationManager.navigateToChat(chat.id) - } catch (e: Exception) { - _state.update { it.copy(error = e.message) } - } - } - } -} diff --git a/client-mobile/core/di/AppModule.kt b/client-mobile/core/di/AppModule.kt deleted file mode 100644 index c8f94c2..0000000 --- a/client-mobile/core/di/AppModule.kt +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 46456ed..0000000 --- a/client-mobile/core/di/NetworkModule.kt +++ /dev/null @@ -1,53 +0,0 @@ -package core.di - -import com.google.gson.GsonBuilder -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 java.util.concurrent.TimeUnit -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() - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(300, TimeUnit.SECONDS) - .addInterceptor(logging) - .addInterceptor(dynamicBaseUrlInterceptor) - .addInterceptor(authInterceptor) - .build() - } - - @Provides - @Singleton - fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { - val gson = com.google.gson.GsonBuilder() - .serializeNulls() - .create() - return Retrofit.Builder() - .baseUrl("https://api.placeholder.com/") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - } -} diff --git a/client-mobile/core/domain/model/ServerConfigModel.kt b/client-mobile/core/domain/model/ServerConfigModel.kt deleted file mode 100644 index c8631a5..0000000 --- a/client-mobile/core/domain/model/ServerConfigModel.kt +++ /dev/null @@ -1,57 +0,0 @@ -package core.domain.model - -import com.google.gson.annotations.SerializedName - -data class ServerConfigModel( - @SerializedName("stories") val stories: StoriesConfig = StoriesConfig(), - @SerializedName("messages") val messages: MessagesConfig = MessagesConfig(), - @SerializedName("chats") val chats: ChatsConfig = ChatsConfig(), - @SerializedName("webRtc") val webRtc: WebRtcConfig = WebRtcConfig() -) { - // Helper to keep the presentation layer simple - val features: FeaturesConfig - get() = FeaturesConfig( - stories = stories.enabled, - polls = messages.allowPolls, - calls = webRtc.enabled && webRtc.enableVoiceCalls, - groups = true // Static for now as chats config doesn't have a simple toggle - ) - - val limits: LimitsConfig - get() = LimitsConfig( - maxFileSize = messages.maxFileSize, - maxGroupMembers = chats.maxGroupParticipants - ) -} - -data class StoriesConfig( - @SerializedName("enabled") val enabled: Boolean = true -) - -data class MessagesConfig( - @SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024, - @SerializedName("allowPolls") val allowPolls: Boolean = true -) - -data class ChatsConfig( - @SerializedName("maxGroupParticipants") val maxGroupParticipants: Int = 200 -) - -data class WebRtcConfig( - @SerializedName("enabled") val enabled: Boolean = true, - @SerializedName("enableVoiceCalls") val enableVoiceCalls: Boolean = true, - @SerializedName("enableVideoCalls") val enableVideoCalls: Boolean = true -) - -// DTOs to maintain compatibility with existing UI code if possible -data class FeaturesConfig( - val stories: Boolean, - val polls: Boolean, - val calls: Boolean, - val groups: Boolean -) - -data class LimitsConfig( - val maxFileSize: Long, - val maxGroupMembers: Int -) diff --git a/client-mobile/core/network/AuthInterceptor.kt b/client-mobile/core/network/AuthInterceptor.kt deleted file mode 100644 index d0f8502..0000000 --- a/client-mobile/core/network/AuthInterceptor.kt +++ /dev/null @@ -1,79 +0,0 @@ -package core.network - -import core.security.TokenManager -import okhttp3.Interceptor -import okhttp3.Response -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.runBlocking - -@Singleton -class AuthInterceptor @Inject constructor( - private val tokenManager: TokenManager, - private val navigationManager: core.utils.NavigationManager, - private val authRepositoryProvider: javax.inject.Provider -) : Interceptor { - - @Volatile - private var isRefreshing = false - - override fun intercept(chain: Interceptor.Chain): Response { - val token = tokenManager.getToken() - val request = chain.request().newBuilder().apply { - token?.let { - addHeader("Authorization", "Bearer $it") - } - }.build() - - var response = chain.proceed(request) - - if (response.code == 401) { - // Attempt to refresh token synchronously - val refreshedToken = refreshAuthToken() - - if (refreshedToken != null) { - // Close the old response before retrying - response.close() - - // Retry the original request with the new token - val retryRequest = chain.request().newBuilder() - .addHeader("Authorization", "Bearer $refreshedToken") - .build() - response = chain.proceed(retryRequest) - } else { - // Refresh failed, logout - logoutUser() - } - } - - return response - } - - private fun refreshAuthToken(): String? { - if (isRefreshing) { - // Already refreshing, wait and return current token - return tokenManager.getToken() - } - - return runBlocking { - isRefreshing = true - try { - val result = authRepositoryProvider.get().refreshToken() - if (result.isSuccess) { - result.getOrNull()?.token - } else { - null - } - } finally { - isRefreshing = false - } - } - } - - private fun logoutUser() { - runBlocking { - authRepositoryProvider.get().logout() - } - navigationManager.logout() - } -} diff --git a/client-mobile/core/network/DynamicBaseUrlInterceptor.kt b/client-mobile/core/network/DynamicBaseUrlInterceptor.kt deleted file mode 100644 index 4456713..0000000 --- a/client-mobile/core/network/DynamicBaseUrlInterceptor.kt +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index fd75a86..0000000 --- a/client-mobile/core/network/ServerConfig.kt +++ /dev/null @@ -1,50 +0,0 @@ -package core.network - -import android.content.Context -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 = context.getSharedPreferences("server_config_prefs", Context.MODE_PRIVATE) - - companion object { - private const val KEY_BASE_URL = "base_url" - private const val KEY_CONFIG_DATA = "server_config_data" - private const val DEFAULT_BASE_URL = "" // Пустой по умолчанию - } - - 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) { - android.util.Log.d("ServerConfig", "Saving new config: $config") - prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).commit() - } - - 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/ActiveChatTracker.kt b/client-mobile/core/notifications/data/ActiveChatTracker.kt deleted file mode 100644 index afce742..0000000 --- a/client-mobile/core/notifications/data/ActiveChatTracker.kt +++ /dev/null @@ -1,28 +0,0 @@ -package core.notifications.data - -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -@Singleton -class ActiveChatTracker @Inject constructor() { - private val _currentChatId = MutableStateFlow(null) - val currentChatId: StateFlow = _currentChatId.asStateFlow() - - private val _totalUnreadCount = MutableStateFlow(0) - val totalUnreadCount: StateFlow = _totalUnreadCount.asStateFlow() - - fun setChatId(chatId: String?) { - _currentChatId.value = chatId - } - - fun setTotalUnreadCount(count: Int) { - _totalUnreadCount.value = count - } - - fun incrementUnreadCount() { - _totalUnreadCount.value += 1 - } -} diff --git a/client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt b/client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt deleted file mode 100644 index 434afda..0000000 --- a/client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt +++ /dev/null @@ -1,58 +0,0 @@ -package core.notifications.data - -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 KnotFirebaseMessagingService : FirebaseMessagingService() { - - @Inject - lateinit var authApi: AuthApi - - @Inject - lateinit var activeChatTracker: ActiveChatTracker - - 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) - android.util.Log.d("FCM", "Message received: ${message.data}") - - val title = message.notification?.title ?: message.data["title"] - val body = message.notification?.body ?: message.data["body"] - val type = message.data["type"] // "chat", "call", "story" - val chatId = message.data["chatId"] - val messageId = message.data["id"] ?: message.messageId - - NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode(), activeChatTracker.totalUnreadCount.value) - } - - private fun showNotification(title: String?, body: String?, type: String?) { - NotificationHelper.showNotification(this, title, body, type, totalCount = activeChatTracker.totalUnreadCount.value) - } - - override fun onDestroy() { - job.cancel() - super.onDestroy() - } -} diff --git a/client-mobile/core/notifications/data/NotificationHelper.kt b/client-mobile/core/notifications/data/NotificationHelper.kt deleted file mode 100644 index afb0e70..0000000 --- a/client-mobile/core/notifications/data/NotificationHelper.kt +++ /dev/null @@ -1,57 +0,0 @@ -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 ru.knot.messager.R - -object NotificationHelper { - private const val CHANNEL_ID = "knot_notifications_channel" - - fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Knot Notifications", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Chat and system notifications" - } - notificationManager.createNotificationChannel(channel) - } - - // Find our launcher activity via package manager to avoid static dependency on MainActivity - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { - action = Intent.ACTION_MAIN - addCategory(Intent.CATEGORY_LAUNCHER) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - putExtra("type", type) - putExtra("chatId", chatId) - } ?: return - - val pendingIntent = PendingIntent.getActivity( - context, System.currentTimeMillis().toInt(), intent, - PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) - ) - - val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) - .setContentText(body) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setDefaults(NotificationCompat.DEFAULT_ALL) - .setNumber(totalCount) - .setContentIntent(pendingIntent) - - notificationManager.notify(finalNotificationId, notificationBuilder.build()) - } -} diff --git a/client-mobile/core/presentation/components/AppAudioPlayer.kt b/client-mobile/core/presentation/components/AppAudioPlayer.kt deleted file mode 100644 index 2d89f46..0000000 --- a/client-mobile/core/presentation/components/AppAudioPlayer.kt +++ /dev/null @@ -1,310 +0,0 @@ -package core.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.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.VolumeUp -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.compose.ui.text.font.FontWeight -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.IntSize -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Composable -fun AppAudioPlayer( - url: String, - name: String? = null, - size: Long? = null, - isVoiceMessage: Boolean = false, - initialPlaybackSpeed: Float = core.utils.PlaybackManager.globalPlaybackSpeed.value, - autoPlay: Boolean = false, - onFinished: (Float) -> Unit = {}, - modifier: Modifier = Modifier, - contentColor: Color = Color.White -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) } - - // Update global speed when changed locally - LaunchedEffect(playbackSpeed) { - core.utils.PlaybackManager.globalPlaybackSpeed.value = playbackSpeed - } - - val exoPlayer = remember { - ExoPlayer.Builder(context).build().apply { - val mediaItem = MediaItem.fromUri(url) - setMediaItem(mediaItem) - playbackParameters = PlaybackParameters(playbackSpeed) - val savedPos = core.utils.PlaybackManager.getPosition(url) - seekTo(savedPos) - prepare() - } - } - - // Обработка сигнала авто-запуска - LaunchedEffect(autoPlay) { - if (autoPlay) { - exoPlayer.play() - core.utils.PlaybackManager.currentPlayingUrl.value = url - } - } - - var isPlaying by remember { mutableStateOf(false) } - var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) } - var duration by remember { mutableLongStateOf(0L) } - var layoutSize by remember { mutableStateOf(IntSize.Zero) } - - // Глобальная синхронизация: если играет кто-то другой, ставим паузу - val globalPlayingUrl by core.utils.PlaybackManager.currentPlayingUrl - LaunchedEffect(globalPlayingUrl) { - if (globalPlayingUrl != url && isPlaying) { - exoPlayer.pause() - } - } - - val fileName = remember(url, name) { - name ?: url.substringAfterLast("/") - } - - DisposableEffect(Unit) { - val listener = object : Player.Listener { - override fun onIsPlayingChanged(playing: Boolean) { - isPlaying = playing - if (playing) { - core.utils.PlaybackManager.currentPlayingUrl.value = url - } - } - override fun onPlaybackStateChanged(state: Int) { - if (state == Player.STATE_READY) { - duration = exoPlayer.duration - } else if (state == Player.STATE_ENDED) { - exoPlayer.pause() // Явно останавливаем, чтобы не было авто-реплея при seekTo - if (isVoiceMessage) { - exoPlayer.seekTo(0) - currentPosition = 0 - core.utils.PlaybackManager.resetPosition(url) - } - onFinished(playbackSpeed) - } - } - } - exoPlayer.addListener(listener) - onDispose { - core.utils.PlaybackManager.savePosition(url, exoPlayer.currentPosition) - exoPlayer.removeListener(listener) - exoPlayer.release() - } - } - - LaunchedEffect(isPlaying) { - if (isPlaying) { - while (isPlaying) { - currentPosition = exoPlayer.currentPosition - core.utils.PlaybackManager.savePosition(url, currentPosition) - delay(200) - } - } - } - - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.12f)) - .padding(8.dp) - ) { - // Header: Icon and Name - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 8.dp) - ) { - Icon( - imageVector = Icons.Default.VolumeUp, - contentDescription = null, - tint = contentColor.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (isVoiceMessage) "Голосовое сообщение" else fileName, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - color = contentColor, - maxLines = 1, - modifier = Modifier.weight(1f) - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - // Play/Pause Button - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.White) - .clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = null, - tint = Color(0xFF3390EC), - modifier = Modifier.size(28.dp) - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - // Waveform - Box( - modifier = Modifier - .fillMaxWidth() - .height(30.dp) - .onSizeChanged { layoutSize = it } - .pointerInput(duration) { - detectTapGestures { offset -> - if (duration > 0 && layoutSize.width > 0) { - val pct = offset.x / layoutSize.width.toFloat() - val newPos = (pct * duration).toLong() - exoPlayer.seekTo(newPos) - currentPosition = newPos - } - } - }, - contentAlignment = Alignment.CenterStart - ) { - androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) { - val barCount = 40 - val barSpacing = 2.dp.toPx() - val barWidth = (this.size.width - (barCount - 1) * barSpacing) / barCount - val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f - - val barHeights = listOf( - 0.3f, 0.5f, 0.2f, 0.7f, 0.4f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f, - 0.2f, 0.6f, 0.5f, 0.8f, 0.3f, 0.7f, 0.4f, 0.6f, 0.2f, 0.8f, - 0.4f, 0.5f, 0.3f, 0.7f, 0.5f, 0.6f, 0.4f, 0.3f, 0.4f, 0.6f, - 0.3f, 0.2f, 0.4f, 0.5f, 0.3f, 0.6f, 0.4f, 0.7f, 0.3f, 0.5f - ) - - for (i in 0 until barCount) { - val x = i * (barWidth + barSpacing) - val heightPct = barHeights[i % barHeights.size] - val barHeight = this.size.height * heightPct - val isActive = (i.toFloat() / barCount) <= progress - - drawRoundRect( - color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f), - topLeft = androidx.compose.ui.geometry.Offset(x, (this.size.height - barHeight) / 2), - size = androidx.compose.ui.geometry.Size(barWidth, barHeight), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()) - ) - } - } - } - - Spacer(modifier = Modifier.height(2.dp)) - - // Footer: Time, Size, Download - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = formatDuration(if (isPlaying || currentPosition > 0) currentPosition else duration), - fontSize = 11.sp, - color = contentColor.copy(alpha = 0.7f) - ) - if (!isVoiceMessage) { - size?.let { - Spacer(modifier = Modifier.width(12.dp)) - val sizeStr = if (it > 1024 * 1024) String.format("%.1f MB", it / (1024.0 * 1024.0)) else "${it / 1024} KB" - Text( - text = sizeStr, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = contentColor.copy(alpha = 0.7f) - ) - } - } - } - - if (!isVoiceMessage) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = null, - tint = contentColor.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp).clickable { - scope.launch { - core.utils.DownloadUtils.downloadMedia(context, url, fileName) - } - } - ) - } - } - } - - // Speed toggle (only for voice messages or if user wants) - if (isVoiceMessage || playbackSpeed > 1.0f) { - Spacer(modifier = Modifier.width(8.dp)) - Surface( - onClick = { - playbackSpeed = when (playbackSpeed) { - 1.0f -> 1.5f - 1.5f -> 2.0f - else -> 1.0f - } - exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed) - }, - color = Color.White.copy(alpha = 0.1f), - shape = CircleShape, - modifier = Modifier.size(32.dp) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) - } - } - } - } - } -} - -private fun formatDuration(durationMs: Long): String { - if (durationMs <= 0) return "0:00" - val totalSeconds = durationMs / 1000 - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return String.format("%d:%02d", minutes, seconds) -} diff --git a/client-mobile/core/presentation/components/AppAvatar.kt b/client-mobile/core/presentation/components/AppAvatar.kt deleted file mode 100644 index 8a22ffb..0000000 --- a/client-mobile/core/presentation/components/AppAvatar.kt +++ /dev/null @@ -1,85 +0,0 @@ -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.runtime.remember -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.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -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 androidx.compose.foundation.shape.RoundedCornerShape -import core.presentation.theme.SoftSquareShape - -@Composable -fun AppAvatar( - url: String?, - name: String, - size: Dp = 48.dp, - modifier: Modifier = Modifier -) { - val isSavedMessages = remember(name) { - name.equals("Избранное", ignoreCase = true) || name.equals("Saved Messages", ignoreCase = true) - } - - val initials = remember(name) { - if (isSavedMessages) "" else { - val words = name.trim().split("\\s+".toRegex()) - if (words.size >= 2) { - (words[0].take(1) + words[1].take(1)).uppercase() - } else if (name.length >= 2) { - name.take(2).uppercase() - } else { - name.take(1).uppercase() - } - } - } - - val avatarColor = Color(0xFF4AA6F3) // Premium Telegram Blue (Web) - - val cornerRadius = remember(size) { - if (size <= 32.dp) (size.value * 0.25).dp else 12.dp - } - - Box( - modifier = modifier - .size(size) - .clip(RoundedCornerShape(cornerRadius)) - .background(avatarColor), - contentAlignment = Alignment.Center - ) { - if (!url.isNullOrBlank()) { - AsyncImage( - model = url, - contentDescription = name, - modifier = Modifier.size(size), - contentScale = ContentScale.Crop - ) - } else if (isSavedMessages) { - Icon( - imageVector = Icons.Default.Bookmark, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size((size.value * 0.5).dp) - ) - } else { - Text( - text = initials, - color = Color.White, - fontSize = (size.value * 0.4).sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } - } -} - diff --git a/client-mobile/core/presentation/components/AppMediaLightbox.kt b/client-mobile/core/presentation/components/AppMediaLightbox.kt deleted file mode 100644 index baf1c6a..0000000 --- a/client-mobile/core/presentation/components/AppMediaLightbox.kt +++ /dev/null @@ -1,241 +0,0 @@ -package core.presentation.components - -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.calculatePan -import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.rememberTransformableState -import androidx.compose.foundation.gestures.transformable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.InsertDriveFile -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.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage -import chats.domain.model.Media -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import android.content.Intent -import android.net.Uri -import kotlinx.coroutines.launch - -@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) -@Composable -fun AppMediaLightbox( - mediaList: List, - initialIndex: Int = 0, - onClose: () -> Unit -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size }) - var canScroll by remember { mutableStateOf(true) } - - Dialog( - onDismissRequest = onClose, - properties = DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false - ) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - pageSpacing = 16.dp, - beyondBoundsPageCount = 1, - userScrollEnabled = canScroll - ) { page -> - val media = mediaList[page] - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - if (media.type.startsWith("video")) { - val isCurrentPage = pagerState.currentPage == page - AppVideoPlayer( - url = media.url, - modifier = Modifier.fillMaxSize(), - useController = true, - autoPlay = isCurrentPage // Только если страница активна - ) - } else if (media.type.startsWith("image") || media.type.contains("gif")) { - var scale by remember { mutableFloatStateOf(1f) } - var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - - // Reset zoom when page changes - LaunchedEffect(pagerState.currentPage) { - scale = 1f - offset = androidx.compose.ui.geometry.Offset.Zero - canScroll = true - } - - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { - if (scale > 1f) { - scale = 1f - offset = Offset.Zero - canScroll = true - } else { - scale = 3f - canScroll = false - } - } - ) - } - .pointerInput(Unit) { - awaitEachGesture { - do { - val event = awaitPointerEvent() - val zoomActive = scale > 1f || event.changes.size > 1 - - if (zoomActive) { - // Если мы в режиме зума - поглощаем события - event.changes.forEach { it.consume() } - - // Сами вычисляем трансформацию - val zoomChange = event.calculateZoom() - val panChange = event.calculatePan() - - scale = (scale * zoomChange).coerceIn(1f, 5f) - canScroll = scale <= 1f - - if (scale > 1f) { - offset += panChange - } else { - offset = Offset.Zero - } - } - // Если zoomActive == false, мы НЕ вызываем consume(), - // и событие уходит наверх в HorizontalPager - } while (event.changes.any { it.pressed }) - } - }, - contentAlignment = Alignment.Center - ) { - AsyncImage( - model = media.url, - imageLoader = core.utils.CoilUtils.getGifImageLoader(LocalContext.current), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offset.x, - translationY = offset.y - ), - contentScale = ContentScale.Fit - ) - } - } - else { - // Document / File placeholder in Lightbox - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.InsertDriveFile, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(100.dp) - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = media.filename ?: "File", - color = Color.White, - style = MaterialTheme.typography.headlineSmall, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(modifier = Modifier.height(32.dp)) - Button( - onClick = { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.url)) - context.startActivity(intent) - } catch (e: Exception) { - // Handle error (no app to open) - } - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black) - ) { - Text("Открыть в приложении") - } - } - } - } - } - - // Top Bar - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onClose, - modifier = Modifier - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)) - ) { - Icon(Icons.Default.Close, contentDescription = null, tint = Color.White) - } - - if (mediaList.isNotEmpty()) { - Text( - text = "${pagerState.currentPage + 1} / ${mediaList.size}", - color = Color.White, - style = MaterialTheme.typography.titleMedium - ) - } - - IconButton( - onClick = { - val currentMedia = mediaList[pagerState.currentPage] - scope.launch { - core.utils.DownloadUtils.downloadMedia(context, currentMedia.url, currentMedia.filename) - } - }, - modifier = Modifier - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)) - ) { - Icon(Icons.Default.Download, contentDescription = null, tint = Color.White) - } - } - } - } -} diff --git a/client-mobile/core/presentation/components/AppVideoPlayer.kt b/client-mobile/core/presentation/components/AppVideoPlayer.kt deleted file mode 100644 index 9d2659e..0000000 --- a/client-mobile/core/presentation/components/AppVideoPlayer.kt +++ /dev/null @@ -1,204 +0,0 @@ -package core.presentation.components - -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.rounded.* -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.unit.dp -import androidx.compose.ui.unit.sp -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 androidx.media3.ui.AspectRatioFrameLayout -import kotlinx.coroutines.delay -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.ui.draw.clip - -@OptIn(UnstableApi::class) -@Composable -fun AppVideoPlayer( - url: String, - modifier: Modifier = Modifier, - useController: Boolean = true, - autoPlay: Boolean = true, - isMuted: Boolean = false, - onVideoFinished: () -> Unit = {} -) { - val context = LocalContext.current - val exoPlayer = remember { - ExoPlayer.Builder(context).build().apply { - val mediaItem = MediaItem.fromUri(url) - setMediaItem(mediaItem) - volume = if (isMuted) 0f else 1f - prepare() - playWhenReady = autoPlay - } - } - - var isPlaying by remember { mutableStateOf(autoPlay) } - var currentPosition by remember { mutableLongStateOf(0L) } - var duration by remember { mutableLongStateOf(0L) } - var isControlsVisible by remember { mutableStateOf(true) } - var isDragging by remember { mutableStateOf(false) } - - 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 - } else if (state == Player.STATE_ENDED) { - onVideoFinished() - } - } - } - exoPlayer.addListener(listener) - onDispose { - exoPlayer.removeListener(listener) - exoPlayer.release() - } - } - - LaunchedEffect(isPlaying, isDragging) { - if (isPlaying && !isDragging) { - while (isPlaying) { - currentPosition = exoPlayer.currentPosition - delay(500) - } - } - } - - Box(modifier = modifier.background(Color.Black)) { - AndroidView( - factory = { - PlayerView(it).apply { - player = exoPlayer - this.useController = false // We use our own UI if needed - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - setBackgroundColor(android.graphics.Color.BLACK) - } - }, - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - enabled = useController // Only clickable if we have controls - ) { - isControlsVisible = !isControlsVisible - } - ) - - AnimatedVisibility( - visible = isControlsVisible && useController, - enter = fadeIn(), - exit = fadeOut() - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) - ) { - // Center Controls - Row( - modifier = Modifier.align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - IconButton( - onClick = { exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) }, - modifier = Modifier.size(48.dp) - ) { - Icon(Icons.Rounded.Replay10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp)) - } - - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.2f)) - .clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(44.dp) - ) - } - - IconButton( - onClick = { exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(duration)) }, - modifier = Modifier.size(48.dp) - ) { - Icon(Icons.Rounded.Forward10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp)) - } - } - - // Bottom Controls - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 16.dp, start = 16.dp, end = 16.dp) - ) { - Slider( - value = currentPosition.toFloat(), - onValueChange = { - isDragging = true - currentPosition = it.toLong() - }, - onValueChangeFinished = { - exoPlayer.seekTo(currentPosition) - isDragging = false - }, - valueRange = 0f..(duration.toFloat().coerceAtLeast(1f)), - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color.White, - inactiveTrackColor = Color.White.copy(alpha = 0.3f) - ), - modifier = Modifier.height(24.dp) - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${formatTime(currentPosition)} · ${formatTime(duration)}", - color = Color.White, - fontSize = 12.sp - ) - - IconButton(onClick = { /* Settings */ }) { - Icon(Icons.Rounded.Settings, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - } - } - } - } - } - } -} - -private fun formatTime(ms: Long): String { - val totalSeconds = ms / 1000 - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return String.format("%02d:%02d", minutes, seconds) -} diff --git a/client-mobile/core/presentation/components/GlassNavigationBar.kt b/client-mobile/core/presentation/components/GlassNavigationBar.kt deleted file mode 100644 index adf74bc..0000000 --- a/client-mobile/core/presentation/components/GlassNavigationBar.kt +++ /dev/null @@ -1,146 +0,0 @@ -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.AccountCircle -import androidx.compose.material.icons.filled.QuestionAnswer -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.text.font.FontWeight -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 = 16.dp, vertical = 12.dp) - .height(64.dp) - .clip(RoundedCornerShape(32.dp)) - .background(Color(0xFF1C1C1E).copy(alpha = 0.95f)) - .border( - 0.5.dp, - Color.White.copy(alpha = 0.1f), - RoundedCornerShape(32.dp) - ), - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - NavItem( - icon = Icons.Default.QuestionAnswer, - label = stringResource(R.string.chats), - isSelected = currentRoute == "chat_list", - onClick = { onNavigate("chat_list") } - ) - NavItem( - icon = Icons.Default.AccountCircle, - 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 RowScope.NavItem( - icon: ImageVector, - label: String, - isSelected: Boolean, - onClick: () -> Unit -) { - val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8) - Column( - modifier = Modifier - .weight(1f) - .clickable(onClick = onClick) - .padding(vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - icon, - contentDescription = label, - tint = contentColor, - modifier = Modifier.size(26.dp) - ) - Text( - text = label, - color = contentColor, - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - modifier = Modifier.padding(top = 2.dp) - ) - } -} - -@Composable -private fun RowScope.ProfileNavItem( - avatarUrl: String?, - username: String, - isSelected: Boolean, - onClick: () -> Unit -) { - val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8) - - Column( - modifier = Modifier - .weight(1f) - .clickable(onClick = onClick) - .padding(vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - AppAvatar( - url = avatarUrl, - name = username, - size = 26.dp - ) - Text( - text = stringResource(R.string.profile_tab), - color = contentColor, - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - modifier = Modifier.padding(top = 2.dp) - ) - } -} diff --git a/client-mobile/core/presentation/settings/SettingsScreen.kt b/client-mobile/core/presentation/settings/SettingsScreen.kt deleted file mode 100644 index 0fd7f7c..0000000 --- a/client-mobile/core/presentation/settings/SettingsScreen.kt +++ /dev/null @@ -1,242 +0,0 @@ -package core.presentation.settings - -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.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -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.graphics.vector.ImageVector -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 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) } - - // Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh - - Scaffold( - containerColor = Color(0xFF0F0F10), - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.settings), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back), tint = Color.White) - } - }, - actions = { - IconButton(onClick = { viewModel.loadConfig() }) { - Icon(Icons.Default.Sync, contentDescription = "Refresh", tint = Color.White) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color(0xFF0F0F10), - titleContentColor = Color.White - ) - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(16.dp) - ) { - if (state.isLoading) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - color = Color(0xFF3390EC) - ) - } - - if (state.error != null) { - Text( - text = "Ошибка обновления: ${state.error}", - color = Color.Red, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(bottom = 16.dp) - ) - } - // Server Section - SettingsSection(title = stringResource(R.string.server_connection)) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = stringResource(R.string.api_base_url), - style = MaterialTheme.typography.labelMedium, - color = Color(0xFF3390EC) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - TextField( - value = baseUrl, - onValueChange = { baseUrl = it }, - modifier = Modifier.weight(1f), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedIndicatorColor = Color(0xFF3390EC), - unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f) - ), - placeholder = { - Text("Например: http://192.168.1.100:5034/api/", color = Color.Gray.copy(alpha = 0.5f)) - } - ) - IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) { - Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC)) - } - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Features Section - SettingsSection(title = stringResource(R.string.server_features)) { - Column { - SettingsToggleItem( - icon = Icons.Default.History, - label = stringResource(R.string.stories), - enabled = state.config.features.stories - ) - SettingsToggleItem( - icon = Icons.Default.Poll, - label = stringResource(R.string.polls), - enabled = state.config.features.polls - ) - SettingsToggleItem( - icon = Icons.Default.Call, - label = stringResource(R.string.calls), - enabled = state.config.features.calls - ) - SettingsToggleItem( - icon = Icons.Default.Group, - label = stringResource(R.string.groups), - enabled = state.config.features.groups - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Limits Section - SettingsSection(title = stringResource(R.string.limits)) { - Column { - SettingsInfoItem( - icon = Icons.Default.Storage, - label = stringResource(R.string.max_file_size), - value = "${state.config.limits.maxFileSize / (1024 * 1024)} MB" - ) - SettingsInfoItem( - icon = Icons.Default.People, - label = stringResource(R.string.max_group_members), - value = state.config.limits.maxGroupMembers.toString() - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Logout Button - Button( - onClick = onLogout, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)), - shape = RoundedCornerShape(12.dp) - ) { - Icon(Icons.Default.Logout, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.log_out), fontWeight = FontWeight.Bold) - } - - Spacer(modifier = Modifier.height(80.dp)) // Space for bottom bar - } - } -} - -@Composable -fun SettingsSection(title: String, content: @Composable () -> Unit) { - Column { - Text( - text = title.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF3390EC), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp) - ) - Surface( - color = Color(0xFF1C1C1E), - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - content() - } - } -} - -@Composable -fun SettingsToggleItem(icon: ImageVector, label: String, enabled: Boolean) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(16.dp)) - Text(label, color = Color.White, fontSize = 16.sp) - } - Text( - text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled), - color = if (enabled) Color(0xFF3390EC) else Color(0xFFE53935), - fontWeight = FontWeight.Bold, - fontSize = 14.sp - ) - } -} - -@Composable -fun SettingsInfoItem(icon: ImageVector, label: String, value: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(16.dp)) - Text(label, color = Color.White, fontSize = 16.sp) - } - Text( - text = value, - color = Color.White, - fontWeight = FontWeight.Medium, - fontSize = 14.sp - ) - } -} diff --git a/client-mobile/core/presentation/settings/SettingsViewModel.kt b/client-mobile/core/presentation/settings/SettingsViewModel.kt deleted file mode 100644 index f9ad5a0..0000000 --- a/client-mobile/core/presentation/settings/SettingsViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package core.presentation.settings - -import android.util.Log -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(), - val isLoading: Boolean = false, - val error: String? = null -) - -@HiltViewModel -class SettingsViewModel @Inject constructor( - private val serverConfig: ServerConfig, - private val authRepository: auth.domain.repository.AuthRepository -) : ViewModel() { - - private val _state = MutableStateFlow(SettingsState()) - val state: StateFlow = _state.asStateFlow() - - init { - refreshState() - // loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён - } - - private fun refreshState() { - _state.update { - it.copy( - baseUrl = serverConfig.getBaseUrl(), - config = serverConfig.getServerConfig() - ) - } - } - - fun loadConfig() { - // Не загружаем конфиг, если URL не введён - val currentUrl = serverConfig.getBaseUrl() - if (currentUrl.isBlank()) { - android.util.Log.d("SettingsViewModel", "Base URL is empty, skipping config load") - return - } - - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - val result = authRepository.fetchConfig() - Log.d("SettingsViewModel", "Fetch result: ${result.isSuccess}") - if (result.isSuccess) { - refreshState() - } else { - _state.update { it.copy(error = result.exceptionOrNull()?.message) } - } - _state.update { it.copy(isLoading = false) } - } - } - - fun saveBaseUrl(url: String) { - val currentUrl = serverConfig.getBaseUrl() - - // Если URL действительно изменился, очищаем токен и логируем - if (currentUrl != url) { - android.util.Log.d("SettingsViewModel", "Server URL changed: $currentUrl -> $url, clearing token") - viewModelScope.launch { - authRepository.logout() - } - } - - 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 deleted file mode 100644 index 3cb7472..0000000 --- a/client-mobile/core/presentation/theme/Theme.kt +++ /dev/null @@ -1,41 +0,0 @@ -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(14.dp) // "Мягкий квадрат" как в вэб - -@Composable -fun KnotTheme(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 deleted file mode 100644 index ff34652..0000000 --- a/client-mobile/core/security/TokenManager.kt +++ /dev/null @@ -1,57 +0,0 @@ -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, userId: String? = null, refreshToken: String? = null) { - val editor = prefs.edit().putString("jwt_token", token) - if (userId != null) { - editor.putString("user_id", userId) - } - if (refreshToken != null) { - editor.putString("refresh_token", refreshToken) - } - editor.apply() - } - - fun getToken(): String? { - return prefs.getString("jwt_token", null) - } - - fun getRefreshToken(): String? { - return prefs.getString("refresh_token", null) - } - - fun getUserId(): String? { - return prefs.getString("user_id", null) - } - - fun getUsername(): String? { - return prefs.getString("username", null) - } - - fun saveUsername(username: String) { - prefs.edit().putString("username", username).apply() - } - - fun deleteToken() { - prefs.edit().remove("jwt_token").remove("user_id").remove("refresh_token").apply() - } -} diff --git a/client-mobile/core/utils/CoilUtils.kt b/client-mobile/core/utils/CoilUtils.kt deleted file mode 100644 index ee4e4c1..0000000 --- a/client-mobile/core/utils/CoilUtils.kt +++ /dev/null @@ -1,26 +0,0 @@ -package core.utils - -import android.content.Context -import android.os.Build -import coil.ImageLoader -import coil.decode.GifDecoder -import coil.decode.ImageDecoderDecoder - -object CoilUtils { - private var gifImageLoader: ImageLoader? = null - - fun getGifImageLoader(context: Context): ImageLoader { - if (gifImageLoader == null) { - gifImageLoader = ImageLoader.Builder(context) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() - } - return gifImageLoader!! - } -} diff --git a/client-mobile/core/utils/DownloadUtils.kt b/client-mobile/core/utils/DownloadUtils.kt deleted file mode 100644 index 425e39a..0000000 --- a/client-mobile/core/utils/DownloadUtils.kt +++ /dev/null @@ -1,90 +0,0 @@ -package core.utils - -import android.app.DownloadManager -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.widget.Toast -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.URL - -object DownloadUtils { - - suspend fun downloadMedia(context: Context, url: String, fileName: String?) { - withContext(Dispatchers.IO) { - try { - val name = fileName ?: url.substringAfterLast("/") - val extension = name.substringAfterLast(".", "jpg") - val isImage = extension in listOf("jpg", "jpeg", "png", "webp", "gif") - val isVideo = extension in listOf("mp4", "webm", "mkv", "mov") - - if (isImage || isVideo) { - saveToGallery(context, url, name, isImage) - } else { - useDownloadManager(context, url, name) - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Ошибка скачивания: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() - } - } - } - } - - private fun useDownloadManager(context: Context, url: String, fileName: String) { - val request = DownloadManager.Request(Uri.parse(url)) - .setTitle(fileName) - .setDescription("Downloading...") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) - .setAllowedOverMetered(true) - .setAllowedOverRoaming(true) - - val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - downloadManager.enqueue(request) - - // Show feedback - (context as? android.app.Activity)?.runOnUiThread { - Toast.makeText(context, "Загрузка началась: $fileName", Toast.LENGTH_SHORT).show() - } - } - - private suspend fun saveToGallery(context: Context, url: String, fileName: String, isImage: Boolean) { - val resolver = context.contentResolver - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - put(MediaStore.MediaColumns.MIME_TYPE, if (isImage) "image/jpeg" else "video/mp4") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.MediaColumns.RELATIVE_PATH, if (isImage) Environment.DIRECTORY_PICTURES else Environment.DIRECTORY_MOVIES) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - } - - val collection = if (isImage) { - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } else { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - - val uri = resolver.insert(collection, contentValues) - uri?.let { - URL(url).openStream().use { input -> - resolver.openOutputStream(it).use { output -> - input.copyTo(output!!) - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentValues.clear() - contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) - resolver.update(it, contentValues, null, null) - } - withContext(Dispatchers.Main) { - Toast.makeText(context, "Сохранено в галерею", Toast.LENGTH_SHORT).show() - } - } - } -} diff --git a/client-mobile/core/utils/FileUtils.kt b/client-mobile/core/utils/FileUtils.kt deleted file mode 100644 index 2e746e4..0000000 --- a/client-mobile/core/utils/FileUtils.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 cursor = context.contentResolver.query(uri, null, null, null, null) - val originalName = cursor?.use { - if (it.moveToFirst()) { - val index = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (index != -1) it.getString(index) else null - } else null - } ?: "${UUID.randomUUID()}.tmp" - - val inputStream = context.contentResolver.openInputStream(uri) - val file = File(context.cacheDir, originalName) - 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 deleted file mode 100644 index ecf123b..0000000 --- a/client-mobile/core/utils/ImageCropper.kt +++ /dev/null @@ -1,32 +0,0 @@ -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/ImageUtils.kt b/client-mobile/core/utils/ImageUtils.kt deleted file mode 100644 index 5e70d96..0000000 --- a/client-mobile/core/utils/ImageUtils.kt +++ /dev/null @@ -1,59 +0,0 @@ -package core.utils - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.net.Uri -import java.io.File -import java.io.FileOutputStream -import java.util.UUID - -object ImageUtils { - fun compressImage(context: Context, uri: Uri, maxWidth: Int = 1280, maxHeight: Int = 1280, quality: Int = 80): File? { - return try { - val inputStream = context.contentResolver.openInputStream(uri) - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeStream(inputStream, null, options) - inputStream?.close() - - var inSampleSize = 1 - if (options.outHeight > maxHeight || options.outWidth > maxWidth) { - val halfHeight = options.outHeight / 2 - val halfWidth = options.outWidth / 2 - while (halfHeight / inSampleSize >= maxHeight && halfWidth / inSampleSize >= maxWidth) { - inSampleSize *= 2 - } - } - - val decodeOptions = BitmapFactory.Options().apply { - inSampleSize = inSampleSize - } - val finalInputStream = context.contentResolver.openInputStream(uri) - var bitmap = BitmapFactory.decodeStream(finalInputStream, null, decodeOptions) - finalInputStream?.close() - - if (bitmap == null) return null - - // Resize if still too large - if (bitmap.width > maxWidth || bitmap.height > maxHeight) { - val scale = Math.min(maxWidth.toFloat() / bitmap.width, maxHeight.toFloat() / bitmap.height) - val matrix = Matrix().apply { postScale(scale, scale) } - bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } - - val file = File(context.cacheDir, "compressed_${UUID.randomUUID()}.jpg") - val out = FileOutputStream(file) - bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out) - out.flush() - out.close() - - file - } catch (e: Exception) { - e.printStackTrace() - null - } - } -} diff --git a/client-mobile/core/utils/LinkParser.kt b/client-mobile/core/utils/LinkParser.kt deleted file mode 100644 index 49b306e..0000000 --- a/client-mobile/core/utils/LinkParser.kt +++ /dev/null @@ -1,20 +0,0 @@ -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?://[^\\s\\n\\r\"]+)", - RegexOption.IGNORE_CASE - ) - - fun findLinks(text: String): List { - if (text.isEmpty()) return emptyList() - return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList() - } -} diff --git a/client-mobile/core/utils/NavigationManager.kt b/client-mobile/core/utils/NavigationManager.kt deleted file mode 100644 index 87c78c9..0000000 --- a/client-mobile/core/utils/NavigationManager.kt +++ /dev/null @@ -1,24 +0,0 @@ -package core.utils - -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableSharedFlow - -sealed class NavEvent { - data class OpenChat(val chatId: String) : NavEvent() - object Logout : NavEvent() -} - -@Singleton -class NavigationManager @Inject constructor() { - private val _events = MutableSharedFlow(extraBufferCapacity = 1) - val events = _events - - fun navigateToChat(chatId: String) { - _events.tryEmit(NavEvent.OpenChat(chatId)) - } - - fun logout() { - _events.tryEmit(NavEvent.Logout) - } -} diff --git a/client-mobile/core/utils/PlaybackManager.kt b/client-mobile/core/utils/PlaybackManager.kt deleted file mode 100644 index 562c458..0000000 --- a/client-mobile/core/utils/PlaybackManager.kt +++ /dev/null @@ -1,30 +0,0 @@ -package core.utils - -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.* - -object PlaybackManager { - // Храним позиции воспроизведения: URL -> Position (Long) - private val savedPositions = mutableMapOf() - - // ID сообщения, которое воспроизводится сейчас (чтобы останавливать другие) - var currentPlayingUrl = mutableStateOf(null) - - // Глобальная скорость проигрывания (сохраняем между треками) - var globalPlaybackSpeed = mutableStateOf(1.0f) - - fun savePosition(url: String, position: Long) { - if (url.isNotBlank()) { - savedPositions[url] = position - } - } - - fun getPosition(url: String): Long { - return savedPositions[url] ?: 0L - } - - fun resetPosition(url: String) { - savedPositions[url] = 0L - } -} diff --git a/client-mobile/core/utils/VoiceRecorder.kt b/client-mobile/core/utils/VoiceRecorder.kt deleted file mode 100644 index ec93383..0000000 --- a/client-mobile/core/utils/VoiceRecorder.kt +++ /dev/null @@ -1,48 +0,0 @@ -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) { - try { - android.util.Log.d("VoiceRecorder", "startRecording to ${outputFile.absolutePath}") - 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() - } - } catch (e: Exception) { - android.util.Log.e("VoiceRecorder", "Failed to start recording", e) - recorder?.release() - recorder = null - } - } - - fun stopRecording() { - try { - android.util.Log.d("VoiceRecorder", "stopRecording") - recorder?.let { - it.stop() - it.release() - android.util.Log.d("VoiceRecorder", "stopRecording success") - } - } catch (e: Exception) { - android.util.Log.e("VoiceRecorder", "Failed to stop recording", e) - } finally { - recorder = null - } - } -} diff --git a/client-mobile/gradle.properties b/client-mobile/gradle.properties deleted file mode 100644 index 197bc4d..0000000 --- a/client-mobile/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -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.jar b/client-mobile/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e644113..0000000 Binary files a/client-mobile/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/client-mobile/gradle/wrapper/gradle-wrapper.properties b/client-mobile/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 1af9e09..0000000 --- a/client-mobile/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -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/gradlew b/client-mobile/gradlew deleted file mode 100644 index b740cf1..0000000 --- a/client-mobile/gradlew +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/client-mobile/gradlew.bat b/client-mobile/gradlew.bat deleted file mode 100644 index 25da30d..0000000 --- a/client-mobile/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/client-mobile/local.properties b/client-mobile/local.properties deleted file mode 100644 index 4b457b4..0000000 --- a/client-mobile/local.properties +++ /dev/null @@ -1,8 +0,0 @@ -## 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 deleted file mode 100644 index 73c72bc..0000000 --- a/client-mobile/navigation/AppNavigation.kt +++ /dev/null @@ -1,271 +0,0 @@ -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.* -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(), - navigationManager: core.utils.NavigationManager? = null -) { - val authViewModel: AuthViewModel = hiltViewModel() - val authState by authViewModel.state.collectAsState() - val startDestination = if (authState.isAuthenticated) Screen.ChatList.route else Screen.Login.route - - androidx.compose.runtime.LaunchedEffect(navigationManager) { - navigationManager?.events?.collect { event -> - when (event) { - is core.utils.NavEvent.OpenChat -> { - if (authState.isAuthenticated) { - navController.navigate(Screen.ChatDetail.createRoute(event.chatId, "Chat")) { - launchSingleTop = true - } - } - } - core.utils.NavEvent.Logout -> { - navController.navigate(Screen.Login.route) { - popUpTo(0) { inclusive = true } - } - } - } - } - } - - 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 - ) - - val profileViewModel: ProfileViewModel = hiltViewModel() - val profileState by profileViewModel.state.collectAsState() - - androidx.compose.runtime.LaunchedEffect(authState.isAuthenticated) { - if (authState.isAuthenticated) { - profileViewModel.loadProfile() - } - } - - Scaffold( - bottomBar = { - if (showBottomBar) { - GlassNavigationBar( - currentRoute = currentRoute ?: "", - onNavigate = { route -> - navController.navigate(route) { - popUpTo(Screen.ChatList.route) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, - userAvatarUrl = profileState.profile?.avatarUrl, - username = profileState.profile?.displayName ?: profileState.profile?.username ?: "" - ) - } - } - ) { 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( - chatId = chatId, - 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, - requests = state.requests, - isLoading = state.isLoading, - onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) }, - onSearchChange = { viewModel.onSearchChange(it) }, - onAddContact = { id -> viewModel.addContact(id) }, - onStartChat = { id -> viewModel.startChat(id) }, - onAcceptRequest = { id -> viewModel.acceptRequest(id) }, - onDeclineRequest = { id -> viewModel.declineRequest(id) } - ) - } - 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) { - // Используем ViewModel от предыдущего экрана (Profile), чтобы данные не терялись - val profileEntry = remember(it) { - navController.getBackStackEntry(Screen.Profile.route) - } - val viewModel: ProfileViewModel = hiltViewModel(profileEntry) - val state by viewModel.state.collectAsState() - - // Получаем profileId из родительского backStackEntry - val profileId = profileEntry.arguments?.getString("profileId") - - androidx.compose.runtime.LaunchedEffect(state.profile) { - // Если профиль загружен (или обновился), мы можем инициализировать/обновить поля - } - - val isSaving = state.isSaving - val error = state.error - - // Автоматически возвращаемся назад после успешного сохранения - var wasSaving by remember { mutableStateOf(false) } - androidx.compose.runtime.LaunchedEffect(isSaving) { - if (isSaving) wasSaving = true - if (wasSaving && !isSaving && error == null) { - // Перезагружаем профиль перед возвратом - viewModel.loadProfile(profileId) - // Небольшая задержка чтобы данные обновились - kotlinx.coroutines.delay(300) - navController.popBackStack() - } - } - - EditProfileScreen( - currentDisplayName = state.profile?.displayName, - currentUsername = state.profile?.username ?: "", - currentBio = state.profile?.bio, - currentBirthday = state.profile?.birthday, - currentAvatarUrl = state.profile?.avatarUrl, - isSaving = isSaving, - error = error, - onSave = { name, bio, birthday, uri -> - viewModel.updateProfile(name, bio, birthday, uri) - }, - 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 deleted file mode 100644 index 97c3566..0000000 --- a/client-mobile/profiles/data/remote/api/ProfileApi.kt +++ /dev/null @@ -1,27 +0,0 @@ -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("auth/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 deleted file mode 100644 index 67caa0e..0000000 --- a/client-mobile/profiles/data/remote/dto/ProfileDto.kt +++ /dev/null @@ -1,29 +0,0 @@ -package profiles.data.remote.dto - -data class ProfileDto( - @com.google.gson.annotations.SerializedName("id", alternate = ["userId"]) - val id: String, - @com.google.gson.annotations.SerializedName("username", alternate = ["userName"]) - val username: String, - @com.google.gson.annotations.SerializedName("displayName") - val displayName: String?, - @com.google.gson.annotations.SerializedName("avatarUrl", alternate = ["avatar"]) - val avatarUrl: String?, - @com.google.gson.annotations.SerializedName("bio", alternate = ["about"]) - val bio: String?, - @com.google.gson.annotations.SerializedName("isOnline") - val isOnline: Boolean = false, - @com.google.gson.annotations.SerializedName("lastSeen") - val lastSeen: String? = null, - @com.google.gson.annotations.SerializedName("birthday") - val birthday: String? = null -) - -data class UpdateProfileRequest( - @com.google.gson.annotations.SerializedName("displayName") - val displayName: String?, - @com.google.gson.annotations.SerializedName("bio") - val bio: String?, - @com.google.gson.annotations.SerializedName("birthday") - val birthday: String? = null -) diff --git a/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt b/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt deleted file mode 100644 index d7c4a72..0000000 --- a/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt +++ /dev/null @@ -1,65 +0,0 @@ -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, - private val serverConfig: core.network.ServerConfig -) : ProfileRepository { - - private fun ProfileDto.fixAvatarUrl(): ProfileDto { - if (avatarUrl == null || avatarUrl.startsWith("http")) return this - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - return copy(avatarUrl = baseUrl + avatarUrl) - } - - override suspend fun getProfile(id: String): Result = runCatching { - api.getProfile(id).fixAvatarUrl() - } - - override suspend fun getMyProfile(): Result = runCatching { - // Сначала получаем ID пользователя из /auth/me - val me = api.getMyProfile() - // Затем загружаем полный профиль из /profiles/{id} - api.getProfile(me.id).fixAvatarUrl() - } - - override suspend fun updateProfile(displayName: String?, bio: String?, birthday: String?): Result = runCatching { - api.updateProfile(UpdateProfileRequest(displayName, bio, birthday)).fixAvatarUrl() - } - - 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).fixAvatarUrl() - } - - override suspend fun removeAvatar(): Result = runCatching { - api.removeAvatar().fixAvatarUrl() - } - - 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 deleted file mode 100644 index efe7026..0000000 --- a/client-mobile/profiles/di/ProfileModule.kt +++ /dev/null @@ -1,33 +0,0 @@ -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, - serverConfig: core.network.ServerConfig - ): ProfileRepository { - return ProfileRepositoryImpl(api, context, serverConfig) - } -} diff --git a/client-mobile/profiles/domain/repository/ProfileRepository.kt b/client-mobile/profiles/domain/repository/ProfileRepository.kt deleted file mode 100644 index 7d38492..0000000 --- a/client-mobile/profiles/domain/repository/ProfileRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -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?, birthday: String? = null): 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 deleted file mode 100644 index 65cf9fb..0000000 --- a/client-mobile/profiles/presentation/EditProfileScreen.kt +++ /dev/null @@ -1,325 +0,0 @@ -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.CalendarToday -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -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.DatePickerDialog -import java.text.SimpleDateFormat -import java.util.* - -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?, - currentBirthday: String?, - currentAvatarUrl: String?, - isSaving: Boolean = false, - error: String? = null, - onSave: (displayName: String, bio: String, birthday: String?, avatarUri: Uri?) -> Unit, - onBack: () -> Unit -) { - val context = LocalContext.current - var displayName by remember(currentDisplayName) { mutableStateOf(currentDisplayName ?: "") } - var bio by remember(currentBio) { mutableStateOf(currentBio ?: "") } - var birthday by remember(currentBirthday) { mutableStateOf(currentBirthday ?: "") } - var selectedImageUri by remember { mutableStateOf(null) } - var showDatePicker by remember { mutableStateOf(false) } - - // Парсим текущую дату для инициализации DatePicker и отображения - val calendar = Calendar.getInstance() - var formattedBirthday by remember { mutableStateOf("") } - - if (birthday.isNotEmpty()) { - try { - // Пробуем распарсить ISO8601 формат (yyyy-MM-dd или yyyy-MM-ddTHH:mm:ss) - val format = when { - birthday.contains("T") -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) - birthday.length == 10 -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - else -> SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) - } - format.parse(birthday)?.let { - calendar.time = it - // Форматируем для отображения: ДД.ММ.ГГГГ - formattedBirthday = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(it) - } - } catch (e: Exception) { - formattedBirthday = birthday - } - } - - // DatePickerDialog - if (showDatePicker) { - DatePickerDialog( - context, - { _, year, month, dayOfMonth -> - val selectedCalendar = Calendar.getInstance() - selectedCalendar.set(year, month, dayOfMonth) - // Используем ISO8601 формат для отправки на сервер - val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - birthday = format.format(selectedCalendar.time) - }, - calendar.get(Calendar.YEAR), - calendar.get(Calendar.MONTH), - calendar.get(Calendar.DAY_OF_MONTH) - ).apply { - datePicker.maxDate = System.currentTimeMillis() - show() - } - showDatePicker = false - } - - // Лаунчер для результата кропа - 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), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, - actions = { - TextButton( - onClick = { onSave(displayName, bio, birthday, selectedImageUri) }, - enabled = !isSaving - ) { - Text( - if (isSaving) stringResource(R.string.saving) else stringResource(R.string.save), - fontWeight = FontWeight.Bold, - color = if (isSaving) Color.Gray else MaterialTheme.colorScheme.primary - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White - ) - ) - } - ) { 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.4f)), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.CameraAlt, - contentDescription = stringResource(R.string.change_photo), - tint = Color.White.copy(alpha = 0.8f), - modifier = Modifier.size(28.dp) - ) - } - } - - 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(16.dp)) - - OutlinedTextField( - value = formattedBirthday, - onValueChange = { }, - label = { Text("Дата рождения") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = SoftSquareShape, - placeholder = { Text("ДД.ММ.ГГГГ") }, - trailingIcon = { - Row { - if (birthday.isNotEmpty()) { - IconButton(onClick = { birthday = "" }) { - Icon( - Icons.Default.Clear, - contentDescription = "Очистить", - tint = Color.Gray - ) - } - } - IconButton(onClick = { showDatePicker = true }) { - Icon( - Icons.Default.CalendarToday, - contentDescription = "Выбрать дату", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - ) - - if (error != null) { - Text( - text = error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = { onSave(displayName, bio, birthday, selectedImageUri) }, - modifier = Modifier.fillMaxWidth(), - shape = SoftSquareShape, - enabled = !isSaving - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.saving)) - } else { - Text(stringResource(R.string.save)) - } - } - } - } -} diff --git a/client-mobile/profiles/presentation/ProfileScreen.kt b/client-mobile/profiles/presentation/ProfileScreen.kt deleted file mode 100644 index 29a06aa..0000000 --- a/client-mobile/profiles/presentation/ProfileScreen.kt +++ /dev/null @@ -1,332 +0,0 @@ -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(Unit) { - android.util.Log.d("ProfileScreen", "Loading profile with profileId=$profileId") - viewModel.loadProfile(profileId) - } - - val profile = state.profile - val isOwnProfile = profileId == null - - android.util.Log.d("ProfileScreen", "Profile loaded: displayName=${profile?.displayName}, bio=${profile?.bio}, birthday=${profile?.birthday}") - - 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), fontWeight = FontWeight.Bold) }, - 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)) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White, - actionIconContentColor = Color.White, - navigationIconContentColor = Color.White - ) - ) - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - item { - ProfileHeader( - username = profile?.username ?: "", - displayName = profile?.displayName, - avatarUrl = profile?.avatarUrl, - bio = profile?.bio, - birthday = profile?.birthday, - 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?, - birthday: 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 || birthday != null) { - Spacer(modifier = Modifier.height(24.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - if (birthday != null) { - Text(text = "Дата рождения", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - // Форматируем дату из ISO8601 в dd.MM.yyyy - val formattedBirthday = try { - val format = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()) - val date = format.parse(birthday) - val outputFormat = java.text.SimpleDateFormat("dd.MM.yyyy", java.util.Locale.getDefault()) - date?.let { outputFormat.format(it) } ?: birthday - } catch (e: Exception) { - birthday - } - Text(text = formattedBirthday, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(8.dp)) - } - if (bio != null) { - 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/knot-messenger") }, - supportingContent = { Text("GitHub - Knot 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 deleted file mode 100644 index d6f85cb..0000000 --- a/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt +++ /dev/null @@ -1,89 +0,0 @@ -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?, birthday: String? = null, avatarUri: Uri? = null) { - viewModelScope.launch { - _state.value = _state.value.copy(isSaving = true, error = null) - - try { - // 1. Обновляем аватар, если он выбран - avatarUri?.let { uri -> - repository.uploadAvatar(uri).onFailure { error -> - throw Exception(error.message ?: "Failed to upload avatar") - } - } - - // 2. Обновляем текстовые данные - // Пустую строку преобразуем в null для даты рождения - val birthdayToSend = if (birthday.isNullOrBlank()) null else birthday - - // Логирование для отладки - android.util.Log.d("ProfileViewModel", "updateProfile: displayName=$displayName, bio=$bio, birthday=$birthday, birthdayToSend=$birthdayToSend") - - repository.updateProfile(displayName, bio, birthdayToSend) - .onSuccess { updatedProfile -> - android.util.Log.d("ProfileViewModel", "updateProfile success: birthday=${updatedProfile.birthday}") - _state.value = _state.value.copy(profile = updatedProfile, isSaving = false) - } - .onFailure { error -> - android.util.Log.e("ProfileViewModel", "updateProfile error: ${error.message}") - _state.value = _state.value.copy(error = error.message, isSaving = false) - } - } catch (e: Exception) { - android.util.Log.e("ProfileViewModel", "updateProfile exception: ${e.message}") - _state.value = _state.value.copy(error = e.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 deleted file mode 100644 index fce93e6..0000000 --- a/client-mobile/settings.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -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") -include(":profiles") -include(":auth") -include(":navigation") -include(":chats") -include(":contacts") -include(":calls") -include(":stories") -include(":settings") -include(":core") diff --git a/client-mobile/settings/presentation/SettingsScreen.kt b/client-mobile/settings/presentation/SettingsScreen.kt deleted file mode 100644 index 2fec055..0000000 --- a/client-mobile/settings/presentation/SettingsScreen.kt +++ /dev/null @@ -1,185 +0,0 @@ -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 deleted file mode 100644 index 6d246b4..0000000 --- a/client-mobile/stories/data/remote/api/StoryApi.kt +++ /dev/null @@ -1,58 +0,0 @@ -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( - @com.google.gson.annotations.SerializedName("userId") - val userId: String?, - @com.google.gson.annotations.SerializedName("username", alternate = ["userName"]) - val username: String?, - @com.google.gson.annotations.SerializedName("avatar", alternate = ["avatarUrl"]) - 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 deleted file mode 100644 index d67551e..0000000 --- a/client-mobile/stories/data/repository/StoryRepositoryImpl.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 94ef504..0000000 --- a/client-mobile/stories/di/StoryModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 4d56962..0000000 --- a/client-mobile/stories/domain/repository/StoryRepository.kt +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 96c27dd..0000000 --- a/client-mobile/stories/presentation/CreateStoryScreen.kt +++ /dev/null @@ -1,316 +0,0 @@ -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 deleted file mode 100644 index adf7beb..0000000 --- a/client-mobile/stories/presentation/CreateStoryViewModel.kt +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index d65898f..0000000 --- a/client-mobile/stories/presentation/StoryViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 1896116..0000000 --- a/client-mobile/stories/presentation/StoryViewerScreen.kt +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index c9d83b5..0000000 --- a/client-mobile/stories/presentation/components/StoryThumbnail.kt +++ /dev/null @@ -1,64 +0,0 @@ -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 displayUsername = username ?: "User" - 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 = displayUsername, - size = 60.dp, - modifier = Modifier.padding(2.dp) - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = displayUsername, - fontSize = 11.sp, - maxLines = 1, - color = MaterialTheme.colorScheme.onSurface - ) - } -}