Удалил к херам нативную версию
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.knot.messager">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name="com.knot.messenger.MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KnotMessenger"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name="com.knot.messenger.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.KnotMessenger">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="core.notifications.data.KnotFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Цветная иконка для notification -->
|
||||
<path
|
||||
android:fillColor="#6C5CE7"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,63 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">Knot</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="register">Register</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="server_connection">Server Connection</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Server Features</string>
|
||||
<string name="stories">Stories</string>
|
||||
<string name="polls">Polls</string>
|
||||
<string name="calls">Calls</string>
|
||||
<string name="groups">Groups</string>
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="limits">Limits</string>
|
||||
<string name="max_file_size">Max File Size</string>
|
||||
<string name="max_group_members">Max Group Members</string>
|
||||
<string name="message">Message</string>
|
||||
<string name="call">Call</string>
|
||||
<string name="block">Block</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="confirm_password">Confirm Password</string>
|
||||
<string name="passwords_not_match">Passwords do not match</string>
|
||||
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||
<string name="already_have_account">Already have an account? Login</string>
|
||||
<string name="error_occurred">An error occurred</string>
|
||||
<string name="loading">Loading...</string>
|
||||
<string name="chats_title">Chats</string>
|
||||
<string name="contacts_title">Contacts</string>
|
||||
<string name="stories_title">Stories</string>
|
||||
<string name="create_story">Create Story</string>
|
||||
<string name="send_message_hint">Type a message...</string>
|
||||
<string name="reply_to_user">Reply to %1$s...</string>
|
||||
<string name="story_editor">STORY EDITOR</string>
|
||||
<string name="publish">PUBLISH</string>
|
||||
<string name="start_creation">START CREATION</string>
|
||||
<string name="text_tool">TEXT</string>
|
||||
<string name="crop_tool">CROP</string>
|
||||
<string name="stickers_tool">STICKERS</string>
|
||||
<string name="brush_tool">BRUSH</string>
|
||||
<string name="filters_tool">FILTERS</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="no_chats_found">No chats found</string>
|
||||
<string name="typing">typing...</string>
|
||||
<string name="video_call">Video Call</string>
|
||||
<string name="emoji">Emoji</string>
|
||||
<string name="attach">Attach</string>
|
||||
<string name="message_placeholder">Message...</string>
|
||||
<string name="voice_message">Voice Message</string>
|
||||
<string name="send">Send</string>
|
||||
<string name="reply_photo">Photo</string>
|
||||
<string name="reply_video">Video</string>
|
||||
<string name="reply_audio">Audio</string>
|
||||
<string name="reply_file">File</string>
|
||||
<string name="reply_gif">GIF</string>
|
||||
<string name="reply_prefix">Reply to </string>
|
||||
<string name="reply_self">yourself</string>
|
||||
</resources>
|
||||
@@ -1,85 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">Knot</string>
|
||||
<string name="login">Войти</string>
|
||||
<string name="register">Регистрация</string>
|
||||
<string name="username">Имя пользователя</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="display_name">Отображаемое имя</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="server_connection">Подключение к серверу</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Функции сервера</string>
|
||||
<string name="stories">Истории</string>
|
||||
<string name="polls">Опросы</string>
|
||||
<string name="calls">Звонки</string>
|
||||
<string name="groups">Группы</string>
|
||||
<string name="enabled">Включено</string>
|
||||
<string name="disabled">Отключено</string>
|
||||
<string name="limits">Лимиты</string>
|
||||
<string name="max_file_size">Макс. размер файла</string>
|
||||
<string name="max_group_members">Макс. участников в группе</string>
|
||||
<string name="message">Сообщение</string>
|
||||
<string name="call">Позвонить</string>
|
||||
<string name="block">Заблокировать</string>
|
||||
<string name="profile">Профиль</string>
|
||||
<string name="confirm_password">Подтвердите пароль</string>
|
||||
<string name="passwords_not_match">Пароли не совпадают</string>
|
||||
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
|
||||
<string name="already_have_account">Уже есть аккаунт? Войти</string>
|
||||
<string name="error_occurred">Произошла ошибка</string>
|
||||
<string name="loading">Загрузка...</string>
|
||||
<string name="chats_title">Чаты</string>
|
||||
<string name="contacts_title">Контакты</string>
|
||||
<string name="stories_title">Истории</string>
|
||||
<string name="create_story">Создать историю</string>
|
||||
<string name="send_message_hint">Напишите сообщение...</string>
|
||||
<string name="reply_to_user">Ответить %1$s...</string>
|
||||
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
|
||||
<string name="publish">ОПУБЛИКОВАТЬ</string>
|
||||
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
|
||||
<string name="text_tool">ТЕКСТ</string>
|
||||
<string name="crop_tool">ОБРЕЗКА</string>
|
||||
<string name="stickers_tool">СТИКЕРЫ</string>
|
||||
<string name="brush_tool">КИСТЬ</string>
|
||||
<string name="filters_tool">ФИЛЬТРЫ</string>
|
||||
<string name="remove">Удалить</string>
|
||||
<string name="no_chats_found">Чаты не найдены</string>
|
||||
<string name="typing">печатает...</string>
|
||||
<string name="video_call">Видеозвонок</string>
|
||||
<string name="emoji">Эмодзи</string>
|
||||
<string name="attach">Прикрепить</string>
|
||||
<string name="message_placeholder">Сообщение...</string>
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="send">Отправить</string>
|
||||
<string name="search_hint">Поиск...</string>
|
||||
<string name="online">В сети</string>
|
||||
<string name="last_seen">Был(а): %1$s</string>
|
||||
<string name="last_seen_recently">недавно</string>
|
||||
<string name="all">Все</string>
|
||||
<string name="online_tab">Онлайн</string>
|
||||
<string name="blocked">Заблокированные</string>
|
||||
<string name="media">Медиа</string>
|
||||
<string name="notifications">Уведомления</string>
|
||||
<string name="mute">Без звука</string>
|
||||
<string name="unmute">Включить звук</string>
|
||||
<string name="log_out">Выйти из аккаунта</string>
|
||||
<string name="bio">О себе</string>
|
||||
<string name="edit_profile">Редактировать профиль</string>
|
||||
<string name="username_label">Имя пользователя</string>
|
||||
<string name="change_photo">Изменить фото</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="crop">Обрезать</string>
|
||||
<string name="chats">Чаты</string>
|
||||
<string name="contacts_tab">Контакты</string>
|
||||
<string name="profile_tab">Профиль</string>
|
||||
<string name="saving">Сохранение...</string>
|
||||
<string name="reply_photo">Фото</string>
|
||||
<string name="reply_video">Видео</string>
|
||||
<string name="reply_audio">Аудио</string>
|
||||
<string name="reply_file">Файл</string>
|
||||
<string name="reply_gif">GIF</string>
|
||||
<string name="reply_prefix">Ответ </string>
|
||||
<string name="reply_self">самому себе</string>
|
||||
</resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">#0F0F10</item>
|
||||
<item name="android:windowBackground">#0F0F10</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Для отладки: доверяем пользовательским сертификатам -->
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="user" />
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<AuthResult> {
|
||||
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<AuthResult> {
|
||||
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<Boolean> {
|
||||
return _isAuthenticated.asStateFlow()
|
||||
}
|
||||
|
||||
override suspend fun fetchConfig(): Result<Unit> {
|
||||
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<AuthResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
package auth.domain.repository
|
||||
|
||||
import auth.domain.model.AuthResult
|
||||
|
||||
interface AuthRepository {
|
||||
suspend fun login(userName: String, password: String): Result<AuthResult>
|
||||
suspend fun register(userName: String, password: String): Result<AuthResult>
|
||||
suspend fun logout()
|
||||
suspend fun fetchConfig(): Result<Unit>
|
||||
fun isAuthenticated(): Boolean
|
||||
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
|
||||
suspend fun updatePushToken(token: String)
|
||||
suspend fun refreshToken(): Result<AuthResult>
|
||||
}
|
||||
@@ -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<AuthState> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String, PeerConnection>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<CallState> = _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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, ParticipantState> = 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<GroupCallState> = _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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, VideoTrack?>,
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<List<ChatEntity>>
|
||||
|
||||
@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<ChatEntity>)
|
||||
|
||||
@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()
|
||||
}
|
||||
@@ -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<List<MessageEntity>>
|
||||
|
||||
/**
|
||||
* Пагинированная загрузка сообщений для 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<MessageEntity>
|
||||
|
||||
@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<MessageEntity>
|
||||
|
||||
@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<MessageEntity>
|
||||
|
||||
@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<MessageStatus>): List<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE status = :status")
|
||||
fun getMessagesByStatusFlow(status: MessageStatus): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE status IN (:statuses)")
|
||||
suspend fun getPendingMessages(statuses: List<MessageStatus>): List<MessageEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessage(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||
|
||||
@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<MessageStatus>): Int
|
||||
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId AND status = :status LIMIT 1")
|
||||
suspend fun getFirstMessageByStatus(chatId: String, status: MessageStatus): MessageEntity?
|
||||
}
|
||||
@@ -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<UserProfileEntity?>
|
||||
|
||||
@Query("SELECT * FROM user_profile")
|
||||
fun getAllUsers(): Flow<List<UserProfileEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertUser(user: UserProfileEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertUsers(users: List<UserProfileEntity>)
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<List<chats.domain.model.Media>>() {}.type
|
||||
val reactionsMapType = object : TypeToken<Map<String, Int>>() {}.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<List<chats.domain.model.Media>>(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<Map<String, Int>>(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()
|
||||
}
|
||||
}
|
||||
@@ -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<Int, MessageEntity>() {
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 30
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
|
||||
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, MessageEntity>): Int? {
|
||||
return state.anchorPosition
|
||||
}
|
||||
}
|
||||
@@ -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<Int, MessageEntity>() {
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 30
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, MessageEntity>
|
||||
): 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<String, Int>()
|
||||
),
|
||||
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
|
||||
}
|
||||
@@ -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<AttachmentRequest>? = 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<ChatDto>
|
||||
|
||||
@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<MessageDto>
|
||||
|
||||
@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<Unit>
|
||||
|
||||
@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<KlipyGifDto>
|
||||
)
|
||||
|
||||
data class KlipyGifDto(
|
||||
val id: String,
|
||||
val images: GifImagesDto? = null,
|
||||
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||
val media_formats: Map<String, GifImageSourceDto>? = 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<GifCategoryDto>
|
||||
)
|
||||
@@ -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<MediaItemDto> = emptyList(),
|
||||
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = 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<MessageDto> = emptyList(),
|
||||
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
|
||||
)
|
||||
|
||||
data class ChatMemberDto(
|
||||
@SerializedName("userId") val userId: String,
|
||||
@SerializedName("user") val user: UserBasicDto? = 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<String>) : 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()
|
||||
}
|
||||
@@ -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<ChatEvent>(extraBufferCapacity = 1024)
|
||||
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||
|
||||
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
val status: StateFlow<ConnectionStatus> = _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<String, Any> ->
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<List<Chat>> {
|
||||
// Single Source of Truth - данные из Room
|
||||
return chatDao.getAllChats().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getChats(): List<Chat> {
|
||||
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<List<Message>> {
|
||||
// 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<PagingData<Message>> {
|
||||
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<Message> {
|
||||
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<chats.data.remote.api.AttachmentRequest>?,
|
||||
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<chats.data.remote.dto.MessageDto>, 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<String, Int>()
|
||||
),
|
||||
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<chats.data.remote.api.KlipyGifDto> {
|
||||
return api.getTrendingGifs(page).data.data
|
||||
}
|
||||
|
||||
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||
return api.searchGifs(query, page).data.data
|
||||
}
|
||||
|
||||
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<ChatSyncWorker>(
|
||||
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<ChatSyncWorker>()
|
||||
.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<chats.data.local.database.ChatEntity>()
|
||||
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<chats.data.remote.dto.MessageDto>,
|
||||
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<String, Int>()
|
||||
),
|
||||
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
|
||||
createdAtMillis = System.currentTimeMillis(),
|
||||
updatedAtMillis = System.currentTimeMillis()
|
||||
)
|
||||
messageDao.insertMessage(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SendMessageWorker>()
|
||||
.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<List<chats.domain.model.Media>>() {}.type
|
||||
gson.fromJson<List<chats.domain.model.Media>>(message.mediaJson, type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList<chats.domain.model.Media>()
|
||||
}
|
||||
|
||||
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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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<Media> = emptyList(),
|
||||
val mediaType: MediaType = MediaType.TEXT,
|
||||
val reactions: Map<String, Int> = 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
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package chats.domain.model
|
||||
|
||||
/**
|
||||
* Статусы сообщения для отображения в UI и синхронизации
|
||||
*/
|
||||
enum class MessageStatus {
|
||||
/** Сообщение создано локально, ожидает отправки */
|
||||
PENDING,
|
||||
|
||||
/** Начата отправка на сервер */
|
||||
SENDING,
|
||||
|
||||
/** Сообщение успешно отправлено на сервер */
|
||||
SENT,
|
||||
|
||||
/** Сообщение доставлено получателю */
|
||||
DELIVERED,
|
||||
|
||||
/** Сообщение прочитано получателем */
|
||||
READ,
|
||||
|
||||
/** Ошибка отправки */
|
||||
FAILED,
|
||||
|
||||
/** Удалено локально */
|
||||
DELETED,
|
||||
|
||||
/** Неизвестный статус */
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -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<List<Chat>>
|
||||
suspend fun getChats(): List<Chat>
|
||||
suspend fun syncChats()
|
||||
|
||||
// Сообщения - Single Source of Truth через Flow
|
||||
fun getMessagesFlow(chatId: String): Flow<List<Message>>
|
||||
fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>>
|
||||
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
|
||||
|
||||
// Отправка сообщений с поддержкой офлайн
|
||||
suspend fun sendMessage(
|
||||
chatId: String,
|
||||
content: String?,
|
||||
type: String = "text",
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>? = 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<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
||||
|
||||
// Управление чатами
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<FileUploadResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Message> = 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<KlipyGifDto> = emptyList(),
|
||||
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
||||
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||||
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||
val isGifsLoading: Boolean = false,
|
||||
val initialScrollIndex: Int? = null,
|
||||
val pendingAttachments: List<File> = emptyList(),
|
||||
val isUploading: Boolean = false,
|
||||
val isCompressionEnabled: Boolean = true,
|
||||
val inputText: String = "",
|
||||
val replyingMessage: Message? = null,
|
||||
val editingMessage: Message? = null,
|
||||
val forwardingMessages: List<Message> = emptyList(),
|
||||
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
|
||||
val selectedMessageIds: Set<String> = emptySet(),
|
||||
val pinnedMessages: List<Message> = 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<ChatDetailState> = _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<Message>) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Chat> = 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<ChatListState> = _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<Chat>): List<Chat> {
|
||||
return chats.sortedWith(compareByDescending<Chat> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<KlipyGifDto>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, LinkMetadata>()
|
||||
|
||||
@Composable
|
||||
fun LinkPreview(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = Color.White
|
||||
) {
|
||||
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<KlipyGifDto>,
|
||||
searchedGifs: List<KlipyGifDto>,
|
||||
recentGifs: List<KlipyGifDto> = emptyList(),
|
||||
gifCategories: List<chats.data.remote.api.GifCategoryDto> = 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<EmojiCategory>,
|
||||
categoryIndices: List<Pair<ImageVector, Int>>,
|
||||
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<String>)
|
||||
|
||||
@Composable
|
||||
private fun GifContent(
|
||||
trendingGifs: List<KlipyGifDto>,
|
||||
searchedGifs: List<KlipyGifDto>,
|
||||
recentGifs: List<KlipyGifDto>,
|
||||
gifCategories: List<chats.data.remote.api.GifCategoryDto>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int?>(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<String, Int>,
|
||||
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<Media>, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String, Int>, // Эмодзи -> Количество
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package contacts.data.remote.api
|
||||
|
||||
import retrofit2.http.*
|
||||
|
||||
interface ContactApi {
|
||||
@GET("contacts")
|
||||
suspend fun getContacts(): List<ContactDto>
|
||||
|
||||
@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<ContactDto>
|
||||
|
||||
@GET("contacts/requests")
|
||||
suspend fun getFriendRequests(): List<FriendRequestDto>
|
||||
|
||||
@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
|
||||
)
|
||||
@@ -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<List<ContactDto>> {
|
||||
return try {
|
||||
Result.success(api.getContacts())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addContact(userId: String): Result<ContactDto> {
|
||||
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<Unit> {
|
||||
return try {
|
||||
api.removeContact(userId)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
|
||||
return try {
|
||||
Result.success(api.searchUsers(query))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
|
||||
return try {
|
||||
Result.success(api.getFriendRequests())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
|
||||
return try {
|
||||
api.acceptRequest(friendshipId)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
|
||||
return try {
|
||||
api.declineRequest(friendshipId)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package contacts.domain.repository
|
||||
|
||||
import contacts.data.remote.api.ContactDto
|
||||
|
||||
interface ContactRepository {
|
||||
suspend fun getContacts(): Result<List<ContactDto>>
|
||||
suspend fun addContact(userId: String): Result<ContactDto>
|
||||
suspend fun removeContact(userId: String): Result<Unit>
|
||||
suspend fun searchUsers(query: String): Result<List<ContactDto>>
|
||||
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
|
||||
suspend fun acceptRequest(friendshipId: String): Result<Unit>
|
||||
suspend fun declineRequest(friendshipId: String): Result<Unit>
|
||||
}
|
||||
@@ -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<contacts.data.remote.api.ContactDto>,
|
||||
requests: List<contacts.data.remote.api.FriendRequestDto> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<contacts.data.remote.api.ContactDto> = emptyList(),
|
||||
val requests: List<contacts.data.remote.api.FriendRequestDto> = 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<ContactListState> = _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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<auth.domain.repository.AuthRepository>
|
||||
) : 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
|
||||
|
||||
private val _totalUnreadCount = MutableStateFlow(0)
|
||||
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount.asStateFlow()
|
||||
|
||||
fun setChatId(chatId: String?) {
|
||||
_currentChatId.value = chatId
|
||||
}
|
||||
|
||||
fun setTotalUnreadCount(count: Int) {
|
||||
_totalUnreadCount.value = count
|
||||
}
|
||||
|
||||
fun incrementUnreadCount() {
|
||||
_totalUnreadCount.value += 1
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Media>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsState> = _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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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!!
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user