From 89e325556c40f2e5a0f9f3196be52e419a1d5865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Wed, 13 May 2026 13:49:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20=D0=BA?= =?UTF-8?q?=20=D1=85=D0=B5=D1=80=D0=B0=D0=BC=20=D0=BD=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=8E=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client-mobile/.gitkeep | 0 client-mobile/app/build.gradle.kts | 146 -- client-mobile/app/google-services.json | 29 - .../app/src/main/AndroidManifest.xml | 42 - .../kotlin/com/knot/messenger/MainActivity.kt | 58 - .../com/knot/messenger/MainApplication.kt | 30 - .../src/main/res/drawable/ic_notification.xml | 13 - .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 3206 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 1832 -> 0 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 4009 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 6984 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 8724 -> 0 bytes .../app/src/main/res/values-en/strings.xml | 63 - .../app/src/main/res/values/strings.xml | 85 -- .../app/src/main/res/values/styles.xml | 7 - .../main/res/xml/network_security_config.xml | 19 - client-mobile/auth/data/remote/api/AuthApi.kt | 29 - .../auth/data/remote/dto/AuthDtos.kt | 28 - .../data/repository/AuthRepositoryImpl.kt | 131 -- client-mobile/auth/di/AuthModule.kt | 34 - client-mobile/auth/domain/model/AuthModels.kt | 10 - .../auth/domain/repository/AuthRepository.kt | 14 - .../auth/presentation/AuthViewModel.kt | 94 -- .../auth/presentation/LoginScreen.kt | 95 -- .../auth/presentation/RegisterScreen.kt | 106 -- client-mobile/build.gradle.kts | 8 - .../calls/data/remote/CallAudioManager.kt | 43 - .../calls/data/remote/GroupWebRtcManager.kt | 46 - .../calls/data/remote/WebRtcManager.kt | 50 - .../calls/presentation/CallScreen.kt | 148 -- .../calls/presentation/CallViewModel.kt | 99 -- .../calls/presentation/GroupCallViewModel.kt | 87 -- .../presentation/components/VideoGrid.kt | 67 - client-mobile/chats/data/local/dao/ChatDao.kt | 45 - .../chats/data/local/dao/MessageDao.kt | 83 -- .../chats/data/local/dao/UserProfileDao.kt | 36 - .../chats/data/local/database/AppDatabase.kt | 33 - .../chats/data/local/database/ChatEntity.kt | 39 - .../data/local/database/MessageEntity.kt | 66 - .../local/database/MessageStatusConverter.kt | 17 - .../data/local/database/UserProfileEntity.kt | 27 - .../chats/data/local/mappers/ChatMappers.kt | 57 - .../data/local/mappers/MessageMappers.kt | 117 -- .../data/local/paging/MessagePagingSource.kt | 43 - .../local/paging/MessageRemoteMediator.kt | 103 -- .../chats/data/remote/api/ChatApi.kt | 130 -- .../chats/data/remote/dto/ChatDtos.kt | 57 - .../chats/data/remote/signalr/ChatEvent.kt | 33 - .../data/remote/signalr/ChatHubClient.kt | 280 ---- .../signalr/SignalRNotificationObserver.kt | 94 -- .../data/repository/ChatRepositoryImpl.kt | 416 ------ .../chats/data/repository/Mappers.kt | 83 -- .../chats/data/workers/ChatSyncWorker.kt | 227 --- .../chats/data/workers/SendMessageWorker.kt | 170 --- client-mobile/chats/di/ChatModule.kt | 37 - client-mobile/chats/di/LocalDatabaseModule.kt | 50 - .../chats/domain/model/ChatModels.kt | 13 - client-mobile/chats/domain/model/Message.kt | 40 - .../chats/domain/model/MessageStatus.kt | 30 - .../chats/domain/repository/ChatRepository.kt | 55 - .../domain/usecase/UploadMediaUseCase.kt | 24 - .../chat_detail/ChatDetailScreen.kt | 1290 ----------------- .../chat_detail/ChatDetailViewModel.kt | 793 ---------- .../presentation/chat_list/ChatListScreen.kt | 127 -- .../chat_list/ChatListViewModel.kt | 183 --- .../chats/presentation/components/ChatItem.kt | 124 -- .../presentation/components/EmojiPicker.kt | 39 - .../presentation/components/KlipyPicker.kt | 84 -- .../presentation/components/LinkPreview.kt | 181 --- .../presentation/components/MediaPicker.kt | 416 ------ .../presentation/components/MessageBubble.kt | 898 ------------ .../components/MessageReactions.kt | 61 - .../components/SwipeableMessageItem.kt | 87 -- .../contacts/data/remote/api/ContactApi.kt | 62 - .../data/repository/ContactRepositoryImpl.kt | 74 - client-mobile/contacts/di/ContactModule.kt | 30 - .../domain/repository/ContactRepository.kt | 13 - .../presentation/ContactListScreen.kt | 253 ---- .../presentation/ContactListViewModel.kt | 122 -- client-mobile/core/di/AppModule.kt | 27 - client-mobile/core/di/NetworkModule.kt | 53 - .../core/domain/model/ServerConfigModel.kt | 57 - client-mobile/core/network/AuthInterceptor.kt | 79 - .../core/network/DynamicBaseUrlInterceptor.kt | 46 - client-mobile/core/network/ServerConfig.kt | 50 - .../notifications/data/ActiveChatTracker.kt | 28 - .../data/KnotFirebaseMessagingService.kt | 58 - .../notifications/data/NotificationHelper.kt | 57 - .../presentation/components/AppAudioPlayer.kt | 310 ---- .../core/presentation/components/AppAvatar.kt | 85 -- .../components/AppMediaLightbox.kt | 241 --- .../presentation/components/AppVideoPlayer.kt | 204 --- .../components/GlassNavigationBar.kt | 146 -- .../presentation/settings/SettingsScreen.kt | 242 ---- .../settings/SettingsViewModel.kt | 81 -- .../core/presentation/theme/Theme.kt | 41 - client-mobile/core/security/TokenManager.kt | 57 - client-mobile/core/utils/CoilUtils.kt | 26 - client-mobile/core/utils/DownloadUtils.kt | 90 -- client-mobile/core/utils/FileUtils.kt | 31 - client-mobile/core/utils/ImageCropper.kt | 32 - client-mobile/core/utils/ImageUtils.kt | 59 - client-mobile/core/utils/LinkParser.kt | 20 - client-mobile/core/utils/NavigationManager.kt | 24 - client-mobile/core/utils/PlaybackManager.kt | 30 - client-mobile/core/utils/VoiceRecorder.kt | 48 - client-mobile/gradle.properties | 4 - .../gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - client-mobile/gradlew | 249 ---- client-mobile/gradlew.bat | 92 -- client-mobile/local.properties | 8 - client-mobile/navigation/AppNavigation.kt | 271 ---- .../profiles/data/remote/api/ProfileApi.kt | 27 - .../profiles/data/remote/dto/ProfileDto.kt | 29 - .../data/repository/ProfileRepositoryImpl.kt | 65 - client-mobile/profiles/di/ProfileModule.kt | 33 - .../domain/repository/ProfileRepository.kt | 12 - .../presentation/EditProfileScreen.kt | 325 ----- .../profiles/presentation/ProfileScreen.kt | 332 ----- .../viewmodel/ProfileViewModel.kt | 89 -- client-mobile/settings.gradle.kts | 27 - .../settings/presentation/SettingsScreen.kt | 185 --- .../stories/data/remote/api/StoryApi.kt | 58 - .../data/repository/StoryRepositoryImpl.kt | 26 - client-mobile/stories/di/StoryModule.kt | 28 - .../domain/repository/StoryRepository.kt | 16 - .../stories/presentation/CreateStoryScreen.kt | 316 ---- .../presentation/CreateStoryViewModel.kt | 165 --- .../stories/presentation/StoryViewModel.kt | 61 - .../stories/presentation/StoryViewerScreen.kt | 192 --- .../presentation/components/StoryThumbnail.kt | 64 - 132 files changed, 13726 deletions(-) delete mode 100644 client-mobile/.gitkeep delete mode 100644 client-mobile/app/build.gradle.kts delete mode 100644 client-mobile/app/google-services.json delete mode 100644 client-mobile/app/src/main/AndroidManifest.xml delete mode 100644 client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt delete mode 100644 client-mobile/app/src/main/kotlin/com/knot/messenger/MainApplication.kt delete mode 100644 client-mobile/app/src/main/res/drawable/ic_notification.xml delete mode 100644 client-mobile/app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 client-mobile/app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 client-mobile/app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 client-mobile/app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 client-mobile/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 client-mobile/app/src/main/res/values-en/strings.xml delete mode 100644 client-mobile/app/src/main/res/values/strings.xml delete mode 100644 client-mobile/app/src/main/res/values/styles.xml delete mode 100644 client-mobile/app/src/main/res/xml/network_security_config.xml delete mode 100644 client-mobile/auth/data/remote/api/AuthApi.kt delete mode 100644 client-mobile/auth/data/remote/dto/AuthDtos.kt delete mode 100644 client-mobile/auth/data/repository/AuthRepositoryImpl.kt delete mode 100644 client-mobile/auth/di/AuthModule.kt delete mode 100644 client-mobile/auth/domain/model/AuthModels.kt delete mode 100644 client-mobile/auth/domain/repository/AuthRepository.kt delete mode 100644 client-mobile/auth/presentation/AuthViewModel.kt delete mode 100644 client-mobile/auth/presentation/LoginScreen.kt delete mode 100644 client-mobile/auth/presentation/RegisterScreen.kt delete mode 100644 client-mobile/build.gradle.kts delete mode 100644 client-mobile/calls/data/remote/CallAudioManager.kt delete mode 100644 client-mobile/calls/data/remote/GroupWebRtcManager.kt delete mode 100644 client-mobile/calls/data/remote/WebRtcManager.kt delete mode 100644 client-mobile/calls/presentation/CallScreen.kt delete mode 100644 client-mobile/calls/presentation/CallViewModel.kt delete mode 100644 client-mobile/calls/presentation/GroupCallViewModel.kt delete mode 100644 client-mobile/calls/presentation/components/VideoGrid.kt delete mode 100644 client-mobile/chats/data/local/dao/ChatDao.kt delete mode 100644 client-mobile/chats/data/local/dao/MessageDao.kt delete mode 100644 client-mobile/chats/data/local/dao/UserProfileDao.kt delete mode 100644 client-mobile/chats/data/local/database/AppDatabase.kt delete mode 100644 client-mobile/chats/data/local/database/ChatEntity.kt delete mode 100644 client-mobile/chats/data/local/database/MessageEntity.kt delete mode 100644 client-mobile/chats/data/local/database/MessageStatusConverter.kt delete mode 100644 client-mobile/chats/data/local/database/UserProfileEntity.kt delete mode 100644 client-mobile/chats/data/local/mappers/ChatMappers.kt delete mode 100644 client-mobile/chats/data/local/mappers/MessageMappers.kt delete mode 100644 client-mobile/chats/data/local/paging/MessagePagingSource.kt delete mode 100644 client-mobile/chats/data/local/paging/MessageRemoteMediator.kt delete mode 100644 client-mobile/chats/data/remote/api/ChatApi.kt delete mode 100644 client-mobile/chats/data/remote/dto/ChatDtos.kt delete mode 100644 client-mobile/chats/data/remote/signalr/ChatEvent.kt delete mode 100644 client-mobile/chats/data/remote/signalr/ChatHubClient.kt delete mode 100644 client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt delete mode 100644 client-mobile/chats/data/repository/ChatRepositoryImpl.kt delete mode 100644 client-mobile/chats/data/repository/Mappers.kt delete mode 100644 client-mobile/chats/data/workers/ChatSyncWorker.kt delete mode 100644 client-mobile/chats/data/workers/SendMessageWorker.kt delete mode 100644 client-mobile/chats/di/ChatModule.kt delete mode 100644 client-mobile/chats/di/LocalDatabaseModule.kt delete mode 100644 client-mobile/chats/domain/model/ChatModels.kt delete mode 100644 client-mobile/chats/domain/model/Message.kt delete mode 100644 client-mobile/chats/domain/model/MessageStatus.kt delete mode 100644 client-mobile/chats/domain/repository/ChatRepository.kt delete mode 100644 client-mobile/chats/domain/usecase/UploadMediaUseCase.kt delete mode 100644 client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt delete mode 100644 client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt delete mode 100644 client-mobile/chats/presentation/chat_list/ChatListScreen.kt delete mode 100644 client-mobile/chats/presentation/chat_list/ChatListViewModel.kt delete mode 100644 client-mobile/chats/presentation/components/ChatItem.kt delete mode 100644 client-mobile/chats/presentation/components/EmojiPicker.kt delete mode 100644 client-mobile/chats/presentation/components/KlipyPicker.kt delete mode 100644 client-mobile/chats/presentation/components/LinkPreview.kt delete mode 100644 client-mobile/chats/presentation/components/MediaPicker.kt delete mode 100644 client-mobile/chats/presentation/components/MessageBubble.kt delete mode 100644 client-mobile/chats/presentation/components/MessageReactions.kt delete mode 100644 client-mobile/chats/presentation/components/SwipeableMessageItem.kt delete mode 100644 client-mobile/contacts/data/remote/api/ContactApi.kt delete mode 100644 client-mobile/contacts/data/repository/ContactRepositoryImpl.kt delete mode 100644 client-mobile/contacts/di/ContactModule.kt delete mode 100644 client-mobile/contacts/domain/repository/ContactRepository.kt delete mode 100644 client-mobile/contacts/presentation/ContactListScreen.kt delete mode 100644 client-mobile/contacts/presentation/ContactListViewModel.kt delete mode 100644 client-mobile/core/di/AppModule.kt delete mode 100644 client-mobile/core/di/NetworkModule.kt delete mode 100644 client-mobile/core/domain/model/ServerConfigModel.kt delete mode 100644 client-mobile/core/network/AuthInterceptor.kt delete mode 100644 client-mobile/core/network/DynamicBaseUrlInterceptor.kt delete mode 100644 client-mobile/core/network/ServerConfig.kt delete mode 100644 client-mobile/core/notifications/data/ActiveChatTracker.kt delete mode 100644 client-mobile/core/notifications/data/KnotFirebaseMessagingService.kt delete mode 100644 client-mobile/core/notifications/data/NotificationHelper.kt delete mode 100644 client-mobile/core/presentation/components/AppAudioPlayer.kt delete mode 100644 client-mobile/core/presentation/components/AppAvatar.kt delete mode 100644 client-mobile/core/presentation/components/AppMediaLightbox.kt delete mode 100644 client-mobile/core/presentation/components/AppVideoPlayer.kt delete mode 100644 client-mobile/core/presentation/components/GlassNavigationBar.kt delete mode 100644 client-mobile/core/presentation/settings/SettingsScreen.kt delete mode 100644 client-mobile/core/presentation/settings/SettingsViewModel.kt delete mode 100644 client-mobile/core/presentation/theme/Theme.kt delete mode 100644 client-mobile/core/security/TokenManager.kt delete mode 100644 client-mobile/core/utils/CoilUtils.kt delete mode 100644 client-mobile/core/utils/DownloadUtils.kt delete mode 100644 client-mobile/core/utils/FileUtils.kt delete mode 100644 client-mobile/core/utils/ImageCropper.kt delete mode 100644 client-mobile/core/utils/ImageUtils.kt delete mode 100644 client-mobile/core/utils/LinkParser.kt delete mode 100644 client-mobile/core/utils/NavigationManager.kt delete mode 100644 client-mobile/core/utils/PlaybackManager.kt delete mode 100644 client-mobile/core/utils/VoiceRecorder.kt delete mode 100644 client-mobile/gradle.properties delete mode 100644 client-mobile/gradle/wrapper/gradle-wrapper.jar delete mode 100644 client-mobile/gradle/wrapper/gradle-wrapper.properties delete mode 100644 client-mobile/gradlew delete mode 100644 client-mobile/gradlew.bat delete mode 100644 client-mobile/local.properties delete mode 100644 client-mobile/navigation/AppNavigation.kt delete mode 100644 client-mobile/profiles/data/remote/api/ProfileApi.kt delete mode 100644 client-mobile/profiles/data/remote/dto/ProfileDto.kt delete mode 100644 client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt delete mode 100644 client-mobile/profiles/di/ProfileModule.kt delete mode 100644 client-mobile/profiles/domain/repository/ProfileRepository.kt delete mode 100644 client-mobile/profiles/presentation/EditProfileScreen.kt delete mode 100644 client-mobile/profiles/presentation/ProfileScreen.kt delete mode 100644 client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt delete mode 100644 client-mobile/settings.gradle.kts delete mode 100644 client-mobile/settings/presentation/SettingsScreen.kt delete mode 100644 client-mobile/stories/data/remote/api/StoryApi.kt delete mode 100644 client-mobile/stories/data/repository/StoryRepositoryImpl.kt delete mode 100644 client-mobile/stories/di/StoryModule.kt delete mode 100644 client-mobile/stories/domain/repository/StoryRepository.kt delete mode 100644 client-mobile/stories/presentation/CreateStoryScreen.kt delete mode 100644 client-mobile/stories/presentation/CreateStoryViewModel.kt delete mode 100644 client-mobile/stories/presentation/StoryViewModel.kt delete mode 100644 client-mobile/stories/presentation/StoryViewerScreen.kt delete mode 100644 client-mobile/stories/presentation/components/StoryThumbnail.kt 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 09264f7553e4ee7650a0bec988f8706e6a0db242..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3206 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 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 e6ae1b2ffbb14a3bd10d8b025b85f7212e748a4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1832 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 P00000NkvXXu0mjf1Ddop 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 bc05396c5da9155a3f2cdc50c4e52c300f3e0f96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6984 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 K(v^~?f&T*%4U)6~ 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 e6441136f3d4ba8a0da8d277868979cfbc8ad796..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43453 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c 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 '} - 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 - ) - } -}