Compare commits
10
Commits
bugfix_web
...
118f8b8971
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Tue Apr 14 00:27:15 MSK 2026
|
||||||
|
gradle.version=8.5
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Tue Apr 14 00:13:58 MSK 2026
|
||||||
|
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("com.google.dagger.hilt.android")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
kotlin("kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.knot.messager"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ru.knot.messager"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подключаем все наши папки с кодом как sourceSets
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
java.srcDirs(
|
||||||
|
"src/main/kotlin",
|
||||||
|
"../auth",
|
||||||
|
"../chats",
|
||||||
|
"../core",
|
||||||
|
"../calls",
|
||||||
|
"../stories",
|
||||||
|
"../contacts",
|
||||||
|
"../profiles",
|
||||||
|
"../settings",
|
||||||
|
"../navigation"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// AndroidX & UI
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.1")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
|
// Network & SignalR
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||||
|
|
||||||
|
// WebRTC
|
||||||
|
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||||
|
|
||||||
|
// Media3 (ExoPlayer)
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-ui:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-common:1.2.0")
|
||||||
|
|
||||||
|
// Images & GIF
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||||
|
|
||||||
|
// Security
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// UCrop (Image Cropping)
|
||||||
|
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||||
|
|
||||||
|
// Firebase (Push Notifications)
|
||||||
|
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "483917912506",
|
||||||
|
"project_id": "knot-bad1a",
|
||||||
|
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "ru.knot.messager"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.KnotMessenger">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.knot.messenger.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
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.ForkFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import navigation.AppNavigation
|
||||||
|
import core.presentation.theme.ForkMessengerTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
ForkMessengerTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
AppNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MainApplication : Application()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ForkMessenger</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>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ForkMessenger</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>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package auth.data.remote.api
|
||||||
|
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.data.remote.dto.AuthResponse
|
||||||
|
import core.domain.model.ServerConfigModel
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface AuthApi {
|
||||||
|
@POST("auth/login")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun login(@Body request: AuthRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("auth/register")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun register(@Body request: AuthRequest): 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package auth.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class AuthRequest(
|
||||||
|
@SerializedName("userName") val userName: String,
|
||||||
|
@SerializedName("password") val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthResponse(
|
||||||
|
@SerializedName("accessToken") val accessToken: String?,
|
||||||
|
@SerializedName("user") val user: UserDto?,
|
||||||
|
@SerializedName("userId") val userId: String?,
|
||||||
|
@SerializedName("username") val username: String?,
|
||||||
|
@SerializedName("displayName") val displayName: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("userName") val userName: String,
|
||||||
|
@SerializedName("displayName") val displayName: String?,
|
||||||
|
@SerializedName("avatarUrl") val avatarUrl: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package auth.data.repository
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.domain.model.AuthResult
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
private val api: AuthApi,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
override suspend fun login(userName: String, password: String): Result<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)
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
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)
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAuthenticated(): Boolean {
|
||||||
|
return tokenManager.getToken() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package auth.di
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.repository.AuthRepositoryImpl
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AuthModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
||||||
|
return retrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRepository(
|
||||||
|
api: AuthApi,
|
||||||
|
tokenManager: TokenManager,
|
||||||
|
serverConfig: ServerConfig
|
||||||
|
): AuthRepository {
|
||||||
|
return AuthRepositoryImpl(api, tokenManager, serverConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth.domain.model
|
||||||
|
|
||||||
|
data class AuthResult(
|
||||||
|
val token: String,
|
||||||
|
val userId: String,
|
||||||
|
val userName: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
suspend fun updatePushToken(token: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (repository.isAuthenticated()) {
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
||||||
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePushToken() {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
val token = task.result
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updatePushToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
onNavigateToRegister: () -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLoginSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var userName by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(state.isAuthenticated) {
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.login)) },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userName,
|
||||||
|
onValueChange = { userName = it },
|
||||||
|
label = { Text(stringResource(R.string.username)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login(userName, password) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = userName.isNotBlank() && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.login))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onNavigateToRegister) {
|
||||||
|
Text(stringResource(R.string.no_account_register))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
onNavigateToLogin: () -> Unit,
|
||||||
|
onRegisterSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var userName by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var errorMessage by remember { mutableStateOf<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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.0" apply false
|
||||||
|
id("com.android.library") version "8.2.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
|
id("com.google.dagger.hilt.android") version "2.48" apply false
|
||||||
|
id("com.google.gms.google-services") version "4.4.0" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package calls.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
class CallAudioManager(private val context: Context) {
|
||||||
|
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
private var originalMode: Int = AudioManager.MODE_NORMAL
|
||||||
|
private var originalIsSpeakerphoneOn: Boolean = false
|
||||||
|
|
||||||
|
fun startCallMode(isVideoCall: Boolean) {
|
||||||
|
originalMode = audioManager.mode
|
||||||
|
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
|
||||||
|
|
||||||
|
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
|
setSpeakerphoneOn(isVideoCall)
|
||||||
|
|
||||||
|
// Запрашиваем фокус аудио
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val playbackAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
.setAudioAttributes(playbackAttributes)
|
||||||
|
.build()
|
||||||
|
audioManager.requestAudioFocus(focusRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeakerphoneOn(on: Boolean) {
|
||||||
|
audioManager.isSpeakerphoneOn = on
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopCallMode() {
|
||||||
|
audioManager.mode = originalMode
|
||||||
|
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
|
||||||
|
audioManager.abandonAudioFocus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package calls.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.webrtc.*
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class GroupWebRtcManager @Inject constructor(private val context: Context) {
|
||||||
|
private val peerConnections = ConcurrentHashMap<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package calls.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.webrtc.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class WebRtcManager @Inject constructor(private val context: Context) {
|
||||||
|
private var peerConnection: PeerConnection? = null
|
||||||
|
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||||
|
|
||||||
|
// Аудио и видео источники
|
||||||
|
private val videoSource by lazy { factory.createVideoSource(false) }
|
||||||
|
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
|
||||||
|
|
||||||
|
private fun createFactory(): PeerConnectionFactory {
|
||||||
|
PeerConnectionFactory.initialize(
|
||||||
|
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||||
|
)
|
||||||
|
return PeerConnectionFactory.builder()
|
||||||
|
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||||
|
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||||
|
.createPeerConnectionFactory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializePeerConnection(observer: PeerConnection.Observer) {
|
||||||
|
val iceServers = listOf(
|
||||||
|
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||||
|
)
|
||||||
|
peerConnection = factory.createPeerConnection(iceServers, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createOffer(observer: SdpObserver) {
|
||||||
|
peerConnection?.createOffer(observer, MediaConstraints())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
|
||||||
|
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addIceCandidate(candidate: IceCandidate) {
|
||||||
|
peerConnection?.addIceCandidate(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
peerConnection?.dispose()
|
||||||
|
peerConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package calls.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallScreen(
|
||||||
|
viewModel: CallViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF0F0F10),
|
||||||
|
Color(0xFF161618),
|
||||||
|
Color(0xFF6366F1).copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Контент звонка (Аватар или Видео)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = 100.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = state.callerAvatar,
|
||||||
|
name = state.callerName,
|
||||||
|
size = 140.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.callerName,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (state.status) {
|
||||||
|
CallStatus.INCOMING -> "Входящий звонок..."
|
||||||
|
CallStatus.OUTGOING -> "Вызов..."
|
||||||
|
CallStatus.CONNECTED -> "00:00" // TODO: Timer
|
||||||
|
CallStatus.ENDED -> "Звонок завершен"
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки управления (Внизу)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 60.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (state.status == CallStatus.INCOMING) {
|
||||||
|
// Кнопка отклонить
|
||||||
|
CallActionButton(
|
||||||
|
icon = Icons.Default.CallEnd,
|
||||||
|
backgroundColor = Color.Red,
|
||||||
|
onClick = { viewModel.endCall(); onBack() }
|
||||||
|
)
|
||||||
|
// Кнопка принять
|
||||||
|
CallActionButton(
|
||||||
|
icon = Icons.Default.Call,
|
||||||
|
backgroundColor = Color(0xFF10B981), // Green
|
||||||
|
onClick = { viewModel.acceptCall() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Стандартные кнопки во время разговора
|
||||||
|
IconButton(
|
||||||
|
onClick = { /* viewModel.toggleMic() */ },
|
||||||
|
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
CallActionButton(
|
||||||
|
icon = Icons.Default.CallEnd,
|
||||||
|
backgroundColor = Color.Red,
|
||||||
|
onClick = { viewModel.endCall(); onBack() }
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { /* viewModel.toggleSpeaker() */ },
|
||||||
|
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallActionButton(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
backgroundColor: Color,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onClick,
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
contentColor = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package calls.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import calls.data.remote.WebRtcManager
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.webrtc.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
|
||||||
|
|
||||||
|
data class CallState(
|
||||||
|
val status: CallStatus = CallStatus.IDLE,
|
||||||
|
val chatId: String? = null,
|
||||||
|
val callerName: String = "",
|
||||||
|
val callerAvatar: String? = null,
|
||||||
|
val isMuted: Boolean = false,
|
||||||
|
val isSpeakerOn: Boolean = false,
|
||||||
|
val localVideoTrack: VideoTrack? = null,
|
||||||
|
val remoteVideoTrack: VideoTrack? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CallViewModel @Inject constructor(
|
||||||
|
private val webRtcManager: WebRtcManager,
|
||||||
|
private val signalrClient: ChatHubClient
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(CallState())
|
||||||
|
val state: StateFlow<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package calls.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import calls.data.remote.GroupWebRtcManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.webrtc.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ParticipantState(
|
||||||
|
val userId: String,
|
||||||
|
val videoTrack: VideoTrack? = null,
|
||||||
|
val isAudioMuted: Boolean = false,
|
||||||
|
val isVideoDisabled: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GroupCallState(
|
||||||
|
val chatId: String? = null,
|
||||||
|
val participants: Map<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package calls.presentation.components
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import org.webrtc.EglBase
|
||||||
|
import org.webrtc.SurfaceViewRenderer
|
||||||
|
import org.webrtc.VideoTrack
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoGrid(
|
||||||
|
participants: Map<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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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("limit") limit: Int? = 50
|
||||||
|
): List<MessageDto>
|
||||||
|
|
||||||
|
@POST("messages/chat/{chatId}")
|
||||||
|
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("messages/upload")
|
||||||
|
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
|
||||||
|
|
||||||
|
// Klipy GIF API
|
||||||
|
@GET("klipy/trending")
|
||||||
|
suspend fun getTrendingGifs(@Query("page") page: Int): 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/{chatId}/typing")
|
||||||
|
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||||
|
|
||||||
|
@POST("chats/{chatId}/read")
|
||||||
|
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class 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>
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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") val id: String,
|
||||||
|
@SerializedName("chatId") val chatId: String? = null,
|
||||||
|
@SerializedName("senderId") val senderId: String? = null,
|
||||||
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("type") val type: String? = null,
|
||||||
|
@SerializedName("sequenceId") val sequenceId: Int? = null,
|
||||||
|
@SerializedName("createdAt") val createdAt: String? = null,
|
||||||
|
@SerializedName("sender") val sender: UserBasicDto? = null,
|
||||||
|
@SerializedName("media") val media: List<MediaItemDto> = emptyList(),
|
||||||
|
@SerializedName("reactions") val reactions: List<ReactionDto>? = emptyList(),
|
||||||
|
@SerializedName("replyTo") val replyTo: MessageDto? = 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") 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
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
|
||||||
|
sealed class ChatEvent {
|
||||||
|
data class NewMessage(val message: MessageDto) : ChatEvent()
|
||||||
|
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
||||||
|
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
||||||
|
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
||||||
|
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class UserOnline(val userId: String) : ChatEvent()
|
||||||
|
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
|
||||||
|
data class NewChat(val chat: ChatDto) : ChatEvent()
|
||||||
|
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String) : ChatEvent()
|
||||||
|
|
||||||
|
// Call Events (WebRTC Signaling)
|
||||||
|
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
|
||||||
|
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
|
||||||
|
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
|
||||||
|
data class CallEnded(val chatId: String) : ChatEvent()
|
||||||
|
|
||||||
|
// Group Call Events
|
||||||
|
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
|
||||||
|
data class GroupCallParticipants(val chatId: String, val participants: List<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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ChatHubClient @Inject constructor() {
|
||||||
|
private var hubConnection: HubConnection? = null
|
||||||
|
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
|
||||||
|
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("messages_read", { chatId: String, userId: String, lastReadSequenceId: Int ->
|
||||||
|
_events.tryEmit(ChatEvent.MessagesRead(chatId, userId, lastReadSequenceId))
|
||||||
|
}, String::class.java, String::class.java, Int::class.java)
|
||||||
|
|
||||||
|
conn.on("user_typing", { chatId: String, userId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.UserTyping(chatId, userId))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("user_online", { userId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.UserOnline(userId))
|
||||||
|
}, String::class.java)
|
||||||
|
|
||||||
|
conn.on("new_chat", { chat: ChatDto ->
|
||||||
|
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||||
|
}, ChatDto::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_added", { messageId: String, chatId: String, userId: String, username: String, emoji: String ->
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
|
||||||
|
}, String::class.java, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_removed", { messageId: String, chatId: String, userId: String, emoji: String ->
|
||||||
|
// Using ReactionUpdated with empty emoji to signal removal or just a specific removal event
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, ""))
|
||||||
|
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
// WebRTC Signaling Handlers
|
||||||
|
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
|
||||||
|
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("call_answered", { chatId: String, answer: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("ice_candidate", { chatId: String, candidate: String ->
|
||||||
|
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("call_ended", { chatId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallEnded(chatId))
|
||||||
|
}, String::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
hubConnection?.stop()
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readMessages(request: ReadMessagesRequest) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("read_messages", request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package chats.data.repository
|
||||||
|
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.api.SendMessageRequest
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.data.remote.dto.MediaItemDto
|
||||||
|
import chats.data.remote.dto.ReactionDto
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class ChatRepositoryImpl @Inject constructor(
|
||||||
|
private val api: ChatApi,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val messageDao: core.database.data.MessageDao,
|
||||||
|
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
|
) : ChatRepository {
|
||||||
|
private val gson = com.google.gson.Gson()
|
||||||
|
|
||||||
|
override suspend fun getChats(): List<Chat> {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
||||||
|
entities.map { it.toDomain(baseUrl, gson) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(chatId: String): List<Message> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return try {
|
||||||
|
val messages = api.getMessages(chatId)
|
||||||
|
// Save to DB
|
||||||
|
messageDao.insertMessages(messages.map { msg: MessageDto -> msg.toEntity(baseUrl, gson) })
|
||||||
|
messages.map { it.toDomain(baseUrl) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If network fails, caller should ideally use the Flow from DB
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(
|
||||||
|
chatId: String,
|
||||||
|
content: String?,
|
||||||
|
type: String,
|
||||||
|
attachments: List<chats.data.remote.api.AttachmentRequest>?
|
||||||
|
): Message {
|
||||||
|
val request = SendMessageRequest(
|
||||||
|
content = content,
|
||||||
|
type = type,
|
||||||
|
attachments = attachments
|
||||||
|
)
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return api.sendMessage(chatId, request).toDomain(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||||
|
api.addReaction(messageId, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendTypingStatus(chatId: String) {
|
||||||
|
api.sendTypingStatus(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
|
val request = chats.data.remote.signalr.ReadMessagesRequest(
|
||||||
|
chatId = chatId,
|
||||||
|
lastReadMessageId = lastMessageId,
|
||||||
|
lastReadSequenceId = lastReadSequenceId
|
||||||
|
)
|
||||||
|
hubClient.readMessages(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveMessage(message: Message) {
|
||||||
|
messageDao.insertMessages(listOf(message.toEntity(gson)))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadMedia(file: java.io.File): String {
|
||||||
|
val requestFile = file.asRequestBody("image/*".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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(baseUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
return core.database.data.MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = senderAvatar,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
mediaType = mediaType.name.lowercase(),
|
||||||
|
mediaJson = gson.toJson(media),
|
||||||
|
reactionsJson = gson.toJson(reactions),
|
||||||
|
isRead = isRead,
|
||||||
|
replyToId = replyTo?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageDto.toDomain(baseUrl: String): Message {
|
||||||
|
val domainMediaType = when (type) {
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> MediaType.FILE
|
||||||
|
else -> when (media.firstOrNull()?.type) {
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> 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,
|
||||||
|
type = it.type,
|
||||||
|
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 = true, // Network messages are usually considered read when fetched or handled by server
|
||||||
|
replyTo = replyTo?.toDomain(baseUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageDto.toEntity(baseUrl: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
return core.database.data.MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId ?: "",
|
||||||
|
senderId = senderId ?: "",
|
||||||
|
senderName = sender?.displayName ?: "Unknown",
|
||||||
|
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId ?: 0,
|
||||||
|
createdAt = createdAt ?: "",
|
||||||
|
mediaType = type ?: "text",
|
||||||
|
mediaJson = gson.toJson(media),
|
||||||
|
reactionsJson = gson.toJson(reactions),
|
||||||
|
isRead = true,
|
||||||
|
replyToId = replyTo?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message {
|
||||||
|
val mediaTypeEnum = when (mediaType) {
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> MediaType.FILE
|
||||||
|
else -> MediaType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
||||||
|
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList()
|
||||||
|
|
||||||
|
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
||||||
|
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = gson.fromJson(reactionsJson, reactionsTypeToken) ?: emptyList()
|
||||||
|
|
||||||
|
return Message(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = senderAvatar,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
media = mediaDtos.map {
|
||||||
|
chats.domain.model.Media(
|
||||||
|
id = it.id,
|
||||||
|
type = it.type,
|
||||||
|
url = it.url.ensureAbsoluteUrl(baseUrl),
|
||||||
|
filename = it.filename,
|
||||||
|
size = it.size,
|
||||||
|
duration = it.duration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
mediaType = mediaTypeEnum,
|
||||||
|
reactions = reactionDtos.associate { it.emoji to it.count },
|
||||||
|
isRead = isRead
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.database.data.MessageDao
|
||||||
|
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(
|
||||||
|
api: ChatApi,
|
||||||
|
tokenManager: TokenManager,
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
messageDao: MessageDao,
|
||||||
|
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
|
): ChatRepository {
|
||||||
|
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatHubClient(): ChatHubClient {
|
||||||
|
return ChatHubClient()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package chats.domain.model
|
||||||
|
|
||||||
|
data class Chat(
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val name: String,
|
||||||
|
val avatar: String?,
|
||||||
|
val unreadCount: Int,
|
||||||
|
val lastMessage: Message?
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package chats.domain.model
|
||||||
|
|
||||||
|
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 replyTo: Message? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package chats.domain.repository
|
||||||
|
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.model.Message
|
||||||
|
|
||||||
|
interface ChatRepository {
|
||||||
|
suspend fun getChats(): List<Chat>
|
||||||
|
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
|
||||||
|
suspend fun getMessages(chatId: String): List<Message>
|
||||||
|
suspend fun sendMessage(
|
||||||
|
chatId: String,
|
||||||
|
content: String?,
|
||||||
|
type: String = "text",
|
||||||
|
attachments: List<chats.data.remote.api.AttachmentRequest>? = null
|
||||||
|
): Message
|
||||||
|
suspend fun addReaction(messageId: String, emoji: String)
|
||||||
|
suspend fun sendTypingStatus(chatId: String)
|
||||||
|
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package chats.domain.usecase
|
||||||
|
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.api.FileUploadResponse
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UploadMediaUseCase @Inject constructor(
|
||||||
|
private val api: ChatApi
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(file: File): Result<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package chats.presentation.chat_detail
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.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.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chats.presentation.components.MediaPicker
|
||||||
|
import chats.presentation.components.MessageBubble
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import core.presentation.components.AppMediaLightbox
|
||||||
|
import core.utils.VoiceRecorder
|
||||||
|
import core.utils.copyUriToFile
|
||||||
|
import java.io.File
|
||||||
|
import ru.knot.messager.R
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatDetailScreen(
|
||||||
|
chatId: String,
|
||||||
|
chatName: String,
|
||||||
|
viewModel: ChatDetailViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val voiceRecorder = remember { VoiceRecorder(context) }
|
||||||
|
var textInput by remember { mutableStateOf("") }
|
||||||
|
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||||
|
var isRecording by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
|
|
||||||
|
var selectedMediaList by remember { mutableStateOf<List<chats.domain.model.Media>?>(null) }
|
||||||
|
var initialMediaIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val playNextVoiceMessage = { currentId: String, speed: Float ->
|
||||||
|
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
|
||||||
|
if (currentIndex != -1 && currentIndex < state.messages.size - 1) {
|
||||||
|
val nextVoiceIndexInSublist = state.messages.subList(currentIndex + 1, state.messages.size)
|
||||||
|
.indexOfFirst { it.mediaType == chats.domain.model.MediaType.AUDIO && it.content.isNullOrEmpty() }
|
||||||
|
|
||||||
|
if (nextVoiceIndexInSublist != -1) {
|
||||||
|
val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist
|
||||||
|
autoPlayingMessageId = state.messages[actualNextIndex].id
|
||||||
|
currentPlaybackSpeed = speed
|
||||||
|
|
||||||
|
// Прокручиваем к следующему сообщению, иначе оно не распарсится LazyColumn
|
||||||
|
scope.launch {
|
||||||
|
listState.animateScrollToItem(actualNextIndex)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoPlayingMessageId = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoPlayingMessageId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пикер галереи
|
||||||
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
val file = copyUriToFile(context, it)
|
||||||
|
file?.let { viewModel.uploadMedia(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка данных чата при входе
|
||||||
|
LaunchedEffect(chatId) {
|
||||||
|
viewModel.setChatId(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автопрокрутка к первому непрочитанному или к самому низу
|
||||||
|
LaunchedEffect(state.initialScrollIndex) {
|
||||||
|
state.initialScrollIndex?.let { index ->
|
||||||
|
if (index < state.messages.size) {
|
||||||
|
listState.scrollToItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автопрокрутка к новому сообщению, если мы внизу
|
||||||
|
LaunchedEffect(state.messages.size) {
|
||||||
|
if (state.messages.isNotEmpty()) {
|
||||||
|
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
|
||||||
|
if (isAtBottom || state.initialScrollIndex == null) {
|
||||||
|
listState.animateScrollToItem(state.messages.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
|
||||||
|
if (isAtBottom) {
|
||||||
|
viewModel.markAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
AppAvatar(
|
||||||
|
url = state.chatAvatar,
|
||||||
|
name = state.chatName ?: chatName,
|
||||||
|
size = 36.dp,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (state.isTyping) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.typing),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.canCall) {
|
||||||
|
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
|
||||||
|
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
|
||||||
|
}
|
||||||
|
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
|
||||||
|
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.imePadding()
|
||||||
|
) {
|
||||||
|
// Список сообщений
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
val allChatMedia = state.messages.flatMap { msg ->
|
||||||
|
msg.media.filter {
|
||||||
|
it.type.startsWith("image") ||
|
||||||
|
it.type.startsWith("video") ||
|
||||||
|
it.filename?.endsWith(".gif", true) == true
|
||||||
|
}.map { it to msg.id }
|
||||||
|
}.reversed()
|
||||||
|
|
||||||
|
items(state.messages, key = { it.id }) { message ->
|
||||||
|
MessageBubble(
|
||||||
|
message = message,
|
||||||
|
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
|
||||||
|
autoPlay = message.id == autoPlayingMessageId,
|
||||||
|
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
||||||
|
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
|
||||||
|
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
|
||||||
|
onMediaClick = { clickedMedia ->
|
||||||
|
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||||
|
selectedMediaList = allChatMedia.map { it.first }
|
||||||
|
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Панель ввода
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
isEmojiPickerVisible = !isEmojiPickerVisible
|
||||||
|
if (isEmojiPickerVisible) {
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.EmojiEmotions,
|
||||||
|
contentDescription = stringResource(R.string.emoji),
|
||||||
|
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { galleryLauncher.launch("*/*") }) {
|
||||||
|
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = textInput,
|
||||||
|
onValueChange = { textInput = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
isEmojiPickerVisible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
||||||
|
maxLines = 4,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (textInput.isBlank()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!isRecording) {
|
||||||
|
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||||
|
voiceRecorder.startRecording(file)
|
||||||
|
isRecording = true
|
||||||
|
} else {
|
||||||
|
voiceRecorder.stopRecording()
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
|
||||||
|
contentDescription = stringResource(R.string.voice_message),
|
||||||
|
tint = if (isRecording) Color.Red else Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = {
|
||||||
|
viewModel.sendMessage(textInput)
|
||||||
|
textInput = ""
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmojiPickerVisible) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadTrendingGifs()
|
||||||
|
}
|
||||||
|
MediaPicker(
|
||||||
|
trendingGifs = state.trendingGifs,
|
||||||
|
searchedGifs = state.searchedGifs,
|
||||||
|
recentGifs = state.recentGifs,
|
||||||
|
gifCategories = state.gifCategories,
|
||||||
|
isGifsLoading = state.isGifsLoading,
|
||||||
|
error = state.error,
|
||||||
|
onEmojiSelected = { textInput += it },
|
||||||
|
onGifSelected = { url ->
|
||||||
|
viewModel.sendGif(url)
|
||||||
|
isEmojiPickerVisible = false
|
||||||
|
},
|
||||||
|
onGifSearch = { query ->
|
||||||
|
viewModel.searchGifs(query)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadTrendingGifs()
|
||||||
|
viewModel.loadGifCategories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Просмотрщик медиа
|
||||||
|
selectedMediaList?.let { list ->
|
||||||
|
AppMediaLightbox(
|
||||||
|
mediaList = list,
|
||||||
|
initialIndex = initialMediaIndex,
|
||||||
|
onClose = { selectedMediaList = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package chats.presentation.chat_detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import chats.data.repository.toDomain
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import 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 chats.data.remote.api.KlipyGifDto
|
||||||
|
|
||||||
|
data class ChatDetailState(
|
||||||
|
val messages: List<Message> = emptyList(),
|
||||||
|
val chatName: String? = null,
|
||||||
|
val chatAvatar: String? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isTyping: Boolean = false,
|
||||||
|
val typingUser: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val canCall: Boolean = true,
|
||||||
|
val maxFileSize: Long = 100 * 1024 * 1024,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatDetailViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
private val signalrClient: ChatHubClient,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatDetailState())
|
||||||
|
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var currentChatId: String? = null
|
||||||
|
private var typingTimerJob: Job? = null
|
||||||
|
private var lastTypingSentTime: Long = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
val config = serverConfig.getServerConfig()
|
||||||
|
_state.update { it.copy(
|
||||||
|
canCall = config.features.calls,
|
||||||
|
maxFileSize = config.limits.maxFileSize
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentUserId(): String {
|
||||||
|
return tokenManager.getUserId() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChatId(chatId: String) {
|
||||||
|
currentChatId = chatId
|
||||||
|
|
||||||
|
// Ensure SignalR is connected
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api")
|
||||||
|
signalrClient.connect(baseUrl, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChatInfo(chatId)
|
||||||
|
observeMessages(chatId)
|
||||||
|
observeSignalREvents()
|
||||||
|
|
||||||
|
// Initial sync from network
|
||||||
|
refreshMessages(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeMessages(chatId: String) {
|
||||||
|
repository.getMessagesFlow(chatId)
|
||||||
|
.onEach { messages ->
|
||||||
|
_state.update { it.copy(messages = messages) }
|
||||||
|
// Calculate scroll index if not set
|
||||||
|
if (_state.value.initialScrollIndex == null && messages.isNotEmpty()) {
|
||||||
|
val firstUnreadIndex = messages.indexOfFirst { !it.isRead && it.senderId != getCurrentUserId() }
|
||||||
|
val targetIndex = if (firstUnreadIndex != -1) firstUnreadIndex else messages.size - 1
|
||||||
|
_state.update { it.copy(initialScrollIndex = targetIndex) }
|
||||||
|
|
||||||
|
// Automark as read
|
||||||
|
markAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshMessages(chatId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.getMessages(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignalREvents() {
|
||||||
|
signalrClient.events
|
||||||
|
.filter { event ->
|
||||||
|
when(event) {
|
||||||
|
is ChatEvent.NewMessage -> event.message.chatId == currentChatId
|
||||||
|
is ChatEvent.ReactionUpdated -> event.chatId == currentChatId
|
||||||
|
is ChatEvent.UserTyping -> event.chatId == currentChatId
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.NewMessage -> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
val domainMsg = event.message.toDomain(baseUrl)
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(domainMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatEvent.ReactionUpdated -> {
|
||||||
|
updateMessageReaction(event.messageId, event.userId, event.emoji)
|
||||||
|
}
|
||||||
|
is ChatEvent.UserTyping -> {
|
||||||
|
_state.update { it.copy(isTyping = true) }
|
||||||
|
// Reset typing status after some delay would be better,
|
||||||
|
// but usually server sends stopped_typing event.
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAsRead() {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
val messages = _state.value.messages
|
||||||
|
if (messages.isEmpty()) return
|
||||||
|
|
||||||
|
val lastMessage = messages.last()
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun sendMessage(text: String) {
|
||||||
|
val chatId = currentChatId ?: return
|
||||||
|
if (text.isBlank()) return
|
||||||
|
|
||||||
|
val tempId = "temp_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = text,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = chats.domain.model.MediaType.TEXT,
|
||||||
|
media = emptyList(),
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val sentMessage = repository.sendMessage(chatId, text)
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
repository.saveMessage(sentMessage)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
_state.update { it.copy(error = e.localizedMessage) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReaction(messageId: String, emoji: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.addReaction(messageId, emoji)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadMedia(file: File) {
|
||||||
|
if (file.length() > _state.value.maxFileSize) {
|
||||||
|
_state.update { it.copy(error = "File too large") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||||
|
val userId = getCurrentUserId()
|
||||||
|
|
||||||
|
val tempMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
content = url,
|
||||||
|
createdAt = java.util.Date().toString(),
|
||||||
|
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
|
||||||
|
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
sequenceId = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveMessage(tempMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = "image",
|
||||||
|
url = url,
|
||||||
|
fileName = "gif.gif",
|
||||||
|
fileSize = 0
|
||||||
|
)
|
||||||
|
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment))
|
||||||
|
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
repository.saveMessage(sentMessage)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
repository.deleteLocalMessage(tempId)
|
||||||
|
_state.update { it.copy(error = e.localizedMessage) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 signalrClient: ChatHubClient,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatListState())
|
||||||
|
val state: StateFlow<ChatListState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val isStoriesEnabled = try {
|
||||||
|
serverConfig.getServerConfig().features.stories
|
||||||
|
} catch (e: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
|
||||||
|
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
signalrClient.connect(baseUrl, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChats()
|
||||||
|
observeSignalREvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
|
||||||
|
|
||||||
|
fun loadChats() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true) }
|
||||||
|
try {
|
||||||
|
val chats = repository.getChats()
|
||||||
|
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == event.message.chatId) {
|
||||||
|
val isMyMessage = event.message.senderId == currentUserId
|
||||||
|
chat.copy(
|
||||||
|
lastMessage = event.message.toDomain(baseUrl),
|
||||||
|
unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1
|
||||||
|
)
|
||||||
|
} else chat
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState.copy(chats = sortChats(updatedChats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmojiPicker(
|
||||||
|
onEmojiSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "✨") // Базовый набор
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(6),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
items(emojis) { emoji ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clickable { onEmojiSelected(emoji) },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = emoji, fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import chats.data.remote.api.KlipyGifDto
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KlipyPicker(
|
||||||
|
gifs: List<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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import core.utils.LinkMetadata
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LinkPreview(
|
||||||
|
metadata: LinkMetadata,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(contentColor.copy(alpha = 0.05f))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
if (metadata.imageUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = metadata.imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(140.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = metadata.url.removePrefix("https://").removePrefix("http://").split("/")[0],
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
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)
|
||||||
|
) {
|
||||||
|
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) { 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)
|
||||||
|
) {
|
||||||
|
items(displayGifs) { 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.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 coil.compose.AsyncImage
|
||||||
|
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.Icon
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import core.presentation.components.AppMediaLightbox
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@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 = {},
|
||||||
|
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showReactionPicker by remember { mutableStateOf(false) }
|
||||||
|
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
val backgroundColor = if (isCurrentUser) {
|
||||||
|
Color(0xFF3096E5)
|
||||||
|
} else {
|
||||||
|
Color(0xFF212121)
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentColor = Color.White
|
||||||
|
|
||||||
|
val shape = if (isCurrentUser) {
|
||||||
|
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
|
||||||
|
(message.content == null || message.content.isEmpty())
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
// No avatars here as per user request (web parity)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { /* Handle normal click */ },
|
||||||
|
onLongClick = { showReactionPicker = true }
|
||||||
|
)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
if (showReactionPicker) {
|
||||||
|
Popup(
|
||||||
|
alignment = Alignment.TopCenter,
|
||||||
|
offset = IntOffset(0, -100),
|
||||||
|
onDismissRequest = { showReactionPicker = false }
|
||||||
|
) {
|
||||||
|
ReactionPicker(onReactionSelected = {
|
||||||
|
onReactionClick(it)
|
||||||
|
showReactionPicker = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 { /* Scroll to reply */ }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(2.dp)
|
||||||
|
.background(if (isCurrentUser) Color.White else Color(0xFF3096E5))
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = reply.content ?: if (reply.media.isNotEmpty()) "\uD83D\uDCCE Media" else "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF Content (Klipy)
|
||||||
|
if (message.mediaType == MediaType.GIF) {
|
||||||
|
AsyncImage(
|
||||||
|
model = message.content,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { /* Handle click */ },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
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 -> {
|
||||||
|
AsyncImage(
|
||||||
|
model = mediaItem.url,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { onMediaClick(mediaItem) },
|
||||||
|
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(
|
||||||
|
message.media,
|
||||||
|
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) {
|
||||||
|
Text(
|
||||||
|
text = message.content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = 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 (isCurrentUser) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = contentColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lightboxMediaIndex?.let { index ->
|
||||||
|
AppMediaLightbox(
|
||||||
|
mediaList = message.media,
|
||||||
|
initialIndex = index,
|
||||||
|
onClose = { lightboxMediaIndex = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(media: List<Media>, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val items = media.take(4)
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
val rows = (items.size + 1) / 2
|
||||||
|
for (i in 0 until rows) {
|
||||||
|
Row(modifier = Modifier.weight(1f)) {
|
||||||
|
val firstIndex = i * 2
|
||||||
|
AsyncImage(
|
||||||
|
model = items[firstIndex].url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex) },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
if (firstIndex + 1 < items.size) {
|
||||||
|
AsyncImage(
|
||||||
|
model = items[firstIndex + 1].url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex + 1) },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else if (rows > 1) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReactionPicker(
|
||||||
|
onReactionSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||||
|
.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
reactions.forEach { reaction ->
|
||||||
|
Text(
|
||||||
|
text = reaction,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clickable { onReactionSelected(reaction) },
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageReactions(
|
||||||
|
reactions: Map<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package contacts.data.remote.api
|
||||||
|
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ContactApi {
|
||||||
|
@GET("contacts")
|
||||||
|
suspend fun getContacts(): List<ContactDto>
|
||||||
|
|
||||||
|
@POST("contacts/add")
|
||||||
|
suspend fun addContact(@Body userId: String): ContactDto
|
||||||
|
|
||||||
|
@DELETE("contacts/{userId}")
|
||||||
|
suspend fun removeContact(@Path("userId") userId: String)
|
||||||
|
|
||||||
|
@GET("users/search")
|
||||||
|
suspend fun searchUsers(@Query("query") query: String): List<ContactDto>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContactDto(
|
||||||
|
val id: String,
|
||||||
|
val username: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
val isOnline: Boolean,
|
||||||
|
val lastSeen: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package contacts.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import core.presentation.theme.SoftSquareShape
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListScreen(
|
||||||
|
contacts: List<ContactDto>,
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
onContactClick: (String) -> Unit,
|
||||||
|
onSearchChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
val tabs = listOf(
|
||||||
|
stringResource(R.string.all),
|
||||||
|
stringResource(R.string.online_tab)
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.contacts_title)) },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
// Поисковая строка
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = {
|
||||||
|
searchQuery = it
|
||||||
|
onSearchChange(it)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
shape = SoftSquareShape
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val filteredContacts = contacts.filter {
|
||||||
|
if (selectedTab == 1) it.isOnline else true
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(filteredContacts) { contact ->
|
||||||
|
ContactItem(contact = contact, onClick = { onContactClick(contact.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(16.dp, 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = contact.avatarUrl,
|
||||||
|
name = contact.username,
|
||||||
|
size = 56.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = contact.displayName ?: contact.username,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
val statusText = if (contact.isOnline) {
|
||||||
|
stringResource(R.string.online)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package contacts.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ContactListState(
|
||||||
|
val contacts: List<ContactDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ContactListViewModel @Inject constructor() : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(ContactListState())
|
||||||
|
val state: StateFlow<ContactListState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onSearchChange(query: String) {
|
||||||
|
// Заглушка поиска
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package core.database.data
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Entity(tableName = "messages")
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val chatId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val senderName: String,
|
||||||
|
val senderAvatar: String?,
|
||||||
|
val content: String?,
|
||||||
|
val sequenceId: Int,
|
||||||
|
val createdAt: String,
|
||||||
|
val mediaType: String,
|
||||||
|
val mediaJson: String, // Simplified for now
|
||||||
|
val reactionsJson: String,
|
||||||
|
val isRead: Boolean,
|
||||||
|
val replyToId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
||||||
|
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
||||||
|
suspend fun clearChat(chatId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE id = :messageId")
|
||||||
|
suspend fun deleteMessage(messageId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Database(entities = [MessageEntity::class], version = 1)
|
||||||
|
abstract class ChatDatabase : RoomDatabase() {
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContext(@ApplicationContext context: Context): Context {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGson(): Gson {
|
||||||
|
return Gson()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
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 DatabaseModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDatabase(@ApplicationContext context: Context): ChatDatabase {
|
||||||
|
return Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
ChatDatabase::class.java,
|
||||||
|
"chat_database"
|
||||||
|
).fallbackToDestructiveMigration().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideMessageDao(database: ChatDatabase): MessageDao {
|
||||||
|
return database.messageDao()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import core.network.AuthInterceptor
|
||||||
|
import core.network.DynamicBaseUrlInterceptor
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
|
||||||
|
): OkHttpClient {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.addInterceptor(dynamicBaseUrlInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl("https://api.placeholder.com/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import core.security.TokenManager
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
val request = chain.request().newBuilder().apply {
|
||||||
|
token?.let {
|
||||||
|
addHeader("Authorization", "Bearer $it")
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DynamicBaseUrlInterceptor @Inject constructor(
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val newBaseUrlString = serverConfig.getBaseUrl()
|
||||||
|
val newBaseUrl = newBaseUrlString.toHttpUrlOrNull() ?: return chain.proceed(originalRequest)
|
||||||
|
|
||||||
|
val oldUrl = originalRequest.url
|
||||||
|
|
||||||
|
// Формируем новый URL.
|
||||||
|
// Retrofit-пути у нас начинаются БЕЗ слеша (например "auth/login")
|
||||||
|
// Поэтому мы просто берем путь из настроек и добавляем к нему путь запроса.
|
||||||
|
val newUrl = newBaseUrl.newBuilder().apply {
|
||||||
|
val pathSegments = oldUrl.pathSegments
|
||||||
|
// Добавляем сегменты пути запроса к базовому пути из настроек
|
||||||
|
pathSegments.forEach { segment ->
|
||||||
|
if (segment.isNotEmpty()) {
|
||||||
|
addEncodedPathSegment(segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Копируем параметры запроса (query params)
|
||||||
|
for (i in 0 until oldUrl.querySize) {
|
||||||
|
addEncodedQueryParameter(
|
||||||
|
oldUrl.queryParameterName(i),
|
||||||
|
oldUrl.queryParameterValue(i)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.url(newUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import core.domain.model.FeaturesConfig
|
||||||
|
import core.domain.model.ServerConfigModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerConfig @Inject constructor(
|
||||||
|
private val context: Context,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences("server_settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_BASE_URL = "https://your-api.com/api/"
|
||||||
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
private const val KEY_CONFIG_DATA = "config_data"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBaseUrl(): String {
|
||||||
|
return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBaseUrl(url: String) {
|
||||||
|
val formattedUrl = if (url.endsWith("/")) url else "$url/"
|
||||||
|
prefs.edit().putString(KEY_BASE_URL, formattedUrl).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveServerConfig(config: ServerConfigModel) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package core.notifications.data
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var authApi: AuthApi
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
// Отправляем новый токен на сервер
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
authApi.updatePushToken(token)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ошибка логируется или игнорируется (сервер может быть недоступен)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
|
||||||
|
val title = message.notification?.title ?: message.data["title"]
|
||||||
|
val body = message.notification?.body ?: message.data["body"]
|
||||||
|
val type = message.data["type"] // "chat", "call", "story"
|
||||||
|
|
||||||
|
showNotification(title, body, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(title: String?, body: String?, type: String?) {
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channelId = "fork_notifications_channel"
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
channelId,
|
||||||
|
"Fork Messenger Notifications",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create intent to open MainActivity
|
||||||
|
val intent = Intent(this, com.knot.messenger.MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
putExtra("type", type)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, intent,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
|
||||||
|
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
job.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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
|
||||||
|
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 { /* Handle download */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 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)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(SoftSquareShape)
|
||||||
|
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package core.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
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.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.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
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AppMediaLightbox(
|
||||||
|
mediaList: List<Media>,
|
||||||
|
initialIndex: Int = 0,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
|
||||||
|
|
||||||
|
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
|
||||||
|
) { 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 {
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = { /* Handle download of mediaList[pagerState.currentPage] */ },
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(26.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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) }
|
||||||
|
|
||||||
|
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||||
|
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
||||||
|
viewModel.loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("https://...", color = Color.Gray) }
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user