Приложение
This commit is contained in:
BIN
client-mobile/.gradle/8.2/checksums/checksums.lock
Normal file
BIN
client-mobile/.gradle/8.2/checksums/checksums.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/checksums/md5-checksums.bin
Normal file
BIN
client-mobile/.gradle/8.2/checksums/md5-checksums.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/checksums/sha1-checksums.bin
Normal file
BIN
client-mobile/.gradle/8.2/checksums/sha1-checksums.bin
Normal file
Binary file not shown.
Binary file not shown.
BIN
client-mobile/.gradle/8.2/executionHistory/executionHistory.bin
Normal file
BIN
client-mobile/.gradle/8.2/executionHistory/executionHistory.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/executionHistory/executionHistory.lock
Normal file
BIN
client-mobile/.gradle/8.2/executionHistory/executionHistory.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/fileChanges/last-build.bin
Normal file
BIN
client-mobile/.gradle/8.2/fileChanges/last-build.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/fileHashes/fileHashes.bin
Normal file
BIN
client-mobile/.gradle/8.2/fileHashes/fileHashes.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/fileHashes/fileHashes.lock
Normal file
BIN
client-mobile/.gradle/8.2/fileHashes/fileHashes.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.2/fileHashes/resourceHashesCache.bin
Normal file
BIN
client-mobile/.gradle/8.2/fileHashes/resourceHashesCache.bin
Normal file
Binary file not shown.
0
client-mobile/.gradle/8.2/gc.properties
Normal file
0
client-mobile/.gradle/8.2/gc.properties
Normal file
BIN
client-mobile/.gradle/8.5/checksums/checksums.lock
Normal file
BIN
client-mobile/.gradle/8.5/checksums/checksums.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/checksums/md5-checksums.bin
Normal file
BIN
client-mobile/.gradle/8.5/checksums/md5-checksums.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/checksums/sha1-checksums.bin
Normal file
BIN
client-mobile/.gradle/8.5/checksums/sha1-checksums.bin
Normal file
Binary file not shown.
Binary file not shown.
BIN
client-mobile/.gradle/8.5/executionHistory/executionHistory.bin
Normal file
BIN
client-mobile/.gradle/8.5/executionHistory/executionHistory.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/executionHistory/executionHistory.lock
Normal file
BIN
client-mobile/.gradle/8.5/executionHistory/executionHistory.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/fileChanges/last-build.bin
Normal file
BIN
client-mobile/.gradle/8.5/fileChanges/last-build.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/fileHashes/fileHashes.bin
Normal file
BIN
client-mobile/.gradle/8.5/fileHashes/fileHashes.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/fileHashes/fileHashes.lock
Normal file
BIN
client-mobile/.gradle/8.5/fileHashes/fileHashes.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin
Normal file
BIN
client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin
Normal file
Binary file not shown.
0
client-mobile/.gradle/8.5/gc.properties
Normal file
0
client-mobile/.gradle/8.5/gc.properties
Normal file
BIN
client-mobile/.gradle/9.0.0/checksums/checksums.lock
Normal file
BIN
client-mobile/.gradle/9.0.0/checksums/checksums.lock
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/9.0.0/checksums/md5-checksums.bin
Normal file
BIN
client-mobile/.gradle/9.0.0/checksums/md5-checksums.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/9.0.0/checksums/sha1-checksums.bin
Normal file
BIN
client-mobile/.gradle/9.0.0/checksums/sha1-checksums.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/9.0.0/fileChanges/last-build.bin
Normal file
BIN
client-mobile/.gradle/9.0.0/fileChanges/last-build.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/9.0.0/fileHashes/fileHashes.bin
Normal file
BIN
client-mobile/.gradle/9.0.0/fileHashes/fileHashes.bin
Normal file
Binary file not shown.
BIN
client-mobile/.gradle/9.0.0/fileHashes/fileHashes.lock
Normal file
BIN
client-mobile/.gradle/9.0.0/fileHashes/fileHashes.lock
Normal file
Binary file not shown.
0
client-mobile/.gradle/9.0.0/gc.properties
Normal file
0
client-mobile/.gradle/9.0.0/gc.properties
Normal file
BIN
client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
BIN
client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Tue Apr 14 00:27:15 MSK 2026
|
||||
gradle.version=8.5
|
||||
BIN
client-mobile/.gradle/buildOutputCleanup/outputFiles.bin
Normal file
BIN
client-mobile/.gradle/buildOutputCleanup/outputFiles.bin
Normal file
Binary file not shown.
2
client-mobile/.gradle/config.properties
Normal file
2
client-mobile/.gradle/config.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
#Tue Apr 14 00:13:58 MSK 2026
|
||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||
0
client-mobile/.gradle/vcs-1/gc.properties
Normal file
0
client-mobile/.gradle/vcs-1/gc.properties
Normal file
124
client-mobile/app/build.gradle.kts
Normal file
124
client-mobile/app/build.gradle.kts
Normal file
@@ -0,0 +1,124 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.gms.google-services")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.knot.messager"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.knot.messager"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
// Подключаем все наши папки с кодом как sourceSets
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs(
|
||||
"src/main/kotlin",
|
||||
"../auth",
|
||||
"../chats",
|
||||
"../core",
|
||||
"../calls",
|
||||
"../stories",
|
||||
"../contacts",
|
||||
"../profiles",
|
||||
"../settings",
|
||||
"../navigation"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX & UI
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||
implementation("androidx.activity:activity-compose:1.8.1")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.48")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Network & SignalR
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||
|
||||
// WebRTC
|
||||
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||
|
||||
// Media3 (ExoPlayer)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||
implementation("androidx.media3:media3-ui:1.2.0")
|
||||
implementation("androidx.media3:media3-common:1.2.0")
|
||||
|
||||
// Images & GIF
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||
|
||||
// Security
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// UCrop (Image Cropping)
|
||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||
|
||||
// Firebase (Push Notifications)
|
||||
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
29
client-mobile/app/google-services.json
Normal file
29
client-mobile/app/google-services.json
Normal file
@@ -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"
|
||||
}
|
||||
37
client-mobile/app/src/main/AndroidManifest.xml
Normal file
37
client-mobile/app/src/main/AndroidManifest.xml
Normal file
@@ -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,29 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import navigation.AppNavigation
|
||||
import core.presentation.theme.ForkMessengerTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
ForkMessengerTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MainApplication : Application()
|
||||
56
client-mobile/app/src/main/res/values-en/strings.xml
Normal file
56
client-mobile/app/src/main/res/values-en/strings.xml
Normal file
@@ -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>
|
||||
77
client-mobile/app/src/main/res/values/strings.xml
Normal file
77
client-mobile/app/src/main/res/values/strings.xml
Normal file
@@ -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>
|
||||
7
client-mobile/app/src/main/res/values/styles.xml
Normal file
7
client-mobile/app/src/main/res/values/styles.xml
Normal file
@@ -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>
|
||||
22
client-mobile/auth/data/remote/api/AuthApi.kt
Normal file
22
client-mobile/auth/data/remote/api/AuthApi.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package auth.data.remote.api
|
||||
|
||||
import auth.data.remote.dto.AuthRequest
|
||||
import auth.data.remote.dto.AuthResponse
|
||||
import core.domain.model.ServerConfigModel
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface AuthApi {
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body request: AuthRequest): AuthResponse
|
||||
|
||||
@POST("auth/register")
|
||||
suspend fun register(@Body request: AuthRequest): AuthResponse
|
||||
|
||||
@GET("config")
|
||||
suspend fun getConfig(): ServerConfigModel
|
||||
|
||||
@POST("auth/push-token")
|
||||
suspend fun updatePushToken(@Body token: String): Unit
|
||||
}
|
||||
23
client-mobile/auth/data/remote/dto/AuthDtos.kt
Normal file
23
client-mobile/auth/data/remote/dto/AuthDtos.kt
Normal file
@@ -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?
|
||||
)
|
||||
72
client-mobile/auth/data/repository/AuthRepositoryImpl.kt
Normal file
72
client-mobile/auth/data/repository/AuthRepositoryImpl.kt
Normal file
@@ -0,0 +1,72 @@
|
||||
package auth.data.repository
|
||||
|
||||
import auth.data.remote.api.AuthApi
|
||||
import auth.data.remote.dto.AuthRequest
|
||||
import auth.domain.model.AuthResult
|
||||
import auth.domain.repository.AuthRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val api: AuthApi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val serverConfig: ServerConfig
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
||||
return try {
|
||||
val response = api.login(AuthRequest(userName, password))
|
||||
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||
|
||||
tokenManager.saveToken(token)
|
||||
fetchConfig()
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = token,
|
||||
userId = response.userId ?: "",
|
||||
userName = response.username ?: userName,
|
||||
displayName = response.displayName ?: response.username ?: userName,
|
||||
avatarUrl = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun register(userName: String, password: String): Result<AuthResult> {
|
||||
return try {
|
||||
val response = api.register(AuthRequest(userName, password))
|
||||
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||
|
||||
tokenManager.saveToken(token)
|
||||
fetchConfig()
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = token,
|
||||
userId = response.userId ?: "",
|
||||
userName = response.username ?: userName,
|
||||
displayName = response.displayName ?: response.username ?: userName,
|
||||
avatarUrl = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
tokenManager.deleteToken()
|
||||
}
|
||||
|
||||
override suspend fun fetchConfig(): Result<Unit> {
|
||||
return try {
|
||||
val config = api.getConfig()
|
||||
serverConfig.saveServerConfig(config)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
client-mobile/auth/di/AuthModule.kt
Normal file
34
client-mobile/auth/di/AuthModule.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
9
client-mobile/auth/domain/model/AuthModels.kt
Normal file
9
client-mobile/auth/domain/model/AuthModels.kt
Normal file
@@ -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?
|
||||
)
|
||||
10
client-mobile/auth/domain/repository/AuthRepository.kt
Normal file
10
client-mobile/auth/domain/repository/AuthRepository.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
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>
|
||||
}
|
||||
53
client-mobile/auth/presentation/AuthViewModel.kt
Normal file
53
client-mobile/auth/presentation/AuthViewModel.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
package auth.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import auth.domain.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AuthState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isAuthenticated: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(AuthState())
|
||||
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||
|
||||
fun login(userName: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
repository.login(userName, password)
|
||||
.onSuccess {
|
||||
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(userName: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
repository.register(userName, password)
|
||||
.onSuccess {
|
||||
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
client-mobile/auth/presentation/LoginScreen.kt
Normal file
95
client-mobile/auth/presentation/LoginScreen.kt
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
client-mobile/auth/presentation/RegisterScreen.kt
Normal file
106
client-mobile/auth/presentation/RegisterScreen.kt
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
client-mobile/build.gradle.kts
Normal file
8
client-mobile/build.gradle.kts
Normal file
@@ -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
|
||||
}
|
||||
43
client-mobile/calls/data/remote/CallAudioManager.kt
Normal file
43
client-mobile/calls/data/remote/CallAudioManager.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
46
client-mobile/calls/data/remote/GroupWebRtcManager.kt
Normal file
46
client-mobile/calls/data/remote/GroupWebRtcManager.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
50
client-mobile/calls/data/remote/WebRtcManager.kt
Normal file
50
client-mobile/calls/data/remote/WebRtcManager.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
148
client-mobile/calls/presentation/CallScreen.kt
Normal file
148
client-mobile/calls/presentation/CallScreen.kt
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
99
client-mobile/calls/presentation/CallViewModel.kt
Normal file
99
client-mobile/calls/presentation/CallViewModel.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
87
client-mobile/calls/presentation/GroupCallViewModel.kt
Normal file
87
client-mobile/calls/presentation/GroupCallViewModel.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
67
client-mobile/calls/presentation/components/VideoGrid.kt
Normal file
67
client-mobile/calls/presentation/components/VideoGrid.kt
Normal file
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
73
client-mobile/chats/data/remote/api/ChatApi.kt
Normal file
73
client-mobile/chats/data/remote/api/ChatApi.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
package chats.data.remote.api
|
||||
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import retrofit2.http.*
|
||||
|
||||
interface ChatApi {
|
||||
@GET("chats")
|
||||
suspend fun getChats(): List<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 content: String): MessageDto
|
||||
|
||||
@Multipart
|
||||
@POST("messages/upload")
|
||||
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
|
||||
|
||||
// Klipy GIF API
|
||||
@GET("klipy/trending")
|
||||
suspend fun getTrendingGifs(@Query("page") page: Int): List<KlipyGifDto>
|
||||
|
||||
@GET("klipy/search")
|
||||
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): List<KlipyGifDto>
|
||||
|
||||
@GET("klipy/categories")
|
||||
suspend fun getGifCategories(): List<GifCategoryDto>
|
||||
|
||||
@POST("klipy/shared/{id}")
|
||||
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
|
||||
|
||||
@POST("messages/{messageId}/reactions")
|
||||
suspend fun addReaction(@Path("messageId") messageId: String, @Body emoji: String)
|
||||
|
||||
@POST("chats/{chatId}/typing")
|
||||
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||
|
||||
@POST("chats/{chatId}/read")
|
||||
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
||||
}
|
||||
|
||||
data class KlipyGifDto(
|
||||
val id: String,
|
||||
val images: GifImagesDto?,
|
||||
val title: String?
|
||||
)
|
||||
|
||||
data class GifImagesDto(
|
||||
val fixed_height: GifImageSourceDto?,
|
||||
val original: GifImageSourceDto?
|
||||
)
|
||||
|
||||
data class GifImageSourceDto(
|
||||
val url: String
|
||||
)
|
||||
|
||||
data class GifCategoryDto(
|
||||
val category: String,
|
||||
val preview_url: String,
|
||||
val query: String
|
||||
)
|
||||
|
||||
data class FileUploadResponse(
|
||||
val url: String,
|
||||
val filename: String,
|
||||
val size: Long
|
||||
)
|
||||
36
client-mobile/chats/data/remote/dto/ChatDtos.kt
Normal file
36
client-mobile/chats/data/remote/dto/ChatDtos.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package chats.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class UserBasicDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("userName") val userName: String,
|
||||
@SerializedName("displayName") val displayName: String,
|
||||
@SerializedName("avatarUrl") val avatarUrl: String?
|
||||
)
|
||||
|
||||
data class MessageDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("chatId") val chatId: String,
|
||||
@SerializedName("senderId") val senderId: String,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("sequenceId") val sequenceId: Int,
|
||||
@SerializedName("createdAt") val createdAt: String,
|
||||
@SerializedName("sender") val sender: UserBasicDto,
|
||||
@SerializedName("media") val media: List<MediaItemDto> = emptyList()
|
||||
)
|
||||
|
||||
data class MediaItemDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("url") val url: String
|
||||
)
|
||||
|
||||
data class ChatDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("name") val name: String?,
|
||||
@SerializedName("unreadCount") val unreadCount: Int,
|
||||
@SerializedName("messages") val messages: List<MessageDto>
|
||||
)
|
||||
31
client-mobile/chats/data/remote/signalr/ChatEvent.kt
Normal file
31
client-mobile/chats/data/remote/signalr/ChatEvent.kt
Normal file
@@ -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()
|
||||
}
|
||||
86
client-mobile/chats/data/remote/signalr/ChatHubClient.kt
Normal file
86
client-mobile/chats/data/remote/signalr/ChatHubClient.kt
Normal file
@@ -0,0 +1,86 @@
|
||||
package chats.data.remote.signalr
|
||||
|
||||
import android.util.Log
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import com.microsoft.signalr.HubConnection
|
||||
import com.microsoft.signalr.HubConnectionBuilder
|
||||
import com.microsoft.signalr.HubConnectionState
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatHubClient @Inject constructor() {
|
||||
private var hubConnection: HubConnection? = null
|
||||
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||
|
||||
fun connect(baseUrl: String, accessToken: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
|
||||
|
||||
hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub")
|
||||
.withAccessTokenProvider(Single.just(accessToken))
|
||||
.build()
|
||||
|
||||
setupHandlers()
|
||||
|
||||
hubConnection?.onClosed { exception ->
|
||||
Log.e("ChatHubClient", "Connection closed", exception)
|
||||
}
|
||||
|
||||
hubConnection?.start()?.blockingAwait()
|
||||
}
|
||||
|
||||
private fun setupHandlers() {
|
||||
hubConnection?.let { conn ->
|
||||
conn.on("new_message", { message: MessageDto ->
|
||||
_events.tryEmit(ChatEvent.NewMessage(message))
|
||||
}, MessageDto::class.java)
|
||||
|
||||
conn.on("messages_read", { chatId: String, userId: String, lastReadSequenceId: Int ->
|
||||
_events.tryEmit(ChatEvent.MessagesRead(chatId, userId, lastReadSequenceId))
|
||||
}, String::class.java, String::class.java, Int::class.java)
|
||||
|
||||
conn.on("user_typing", { chatId: String, userId: String ->
|
||||
_events.tryEmit(ChatEvent.UserTyping(chatId, userId))
|
||||
}, String::class.java, String::class.java)
|
||||
|
||||
conn.on("user_online", { userId: String ->
|
||||
_events.tryEmit(ChatEvent.UserOnline(userId))
|
||||
}, String::class.java)
|
||||
|
||||
conn.on("new_chat", { chat: ChatDto ->
|
||||
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||
}, ChatDto::class.java)
|
||||
|
||||
conn.on("reaction_updated", { messageId: String, chatId: String, userId: String, emoji: String ->
|
||||
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
|
||||
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||
|
||||
// WebRTC Signaling Handlers
|
||||
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
|
||||
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
|
||||
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||
|
||||
conn.on("call_answered", { chatId: String, answer: String ->
|
||||
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
|
||||
}, String::class.java, String::class.java)
|
||||
|
||||
conn.on("ice_candidate", { chatId: String, candidate: String ->
|
||||
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
|
||||
}, String::class.java, String::class.java)
|
||||
|
||||
conn.on("call_ended", { chatId: String ->
|
||||
_events.tryEmit(ChatEvent.CallEnded(chatId))
|
||||
}, String::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
hubConnection?.stop()
|
||||
}
|
||||
}
|
||||
68
client-mobile/chats/data/repository/ChatRepositoryImpl.kt
Normal file
68
client-mobile/chats/data/repository/ChatRepositoryImpl.kt
Normal file
@@ -0,0 +1,68 @@
|
||||
package chats.data.repository
|
||||
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.model.MediaType
|
||||
import chats.domain.repository.ChatRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class ChatRepositoryImpl @Inject constructor(
|
||||
private val api: ChatApi
|
||||
) : ChatRepository {
|
||||
|
||||
override suspend fun getChats(): List<Chat> {
|
||||
return api.getChats().map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun getMessages(chatId: String): List<Message> {
|
||||
return api.getMessages(chatId).map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(chatId: String, content: String): Message {
|
||||
return api.sendMessage(chatId, content).toDomain()
|
||||
}
|
||||
|
||||
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||
api.addReaction(messageId, emoji)
|
||||
}
|
||||
|
||||
override suspend fun sendTypingStatus(chatId: String) {
|
||||
api.sendTypingStatus(chatId)
|
||||
}
|
||||
|
||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) {
|
||||
api.markMessagesAsRead(chatId, lastMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
// Mappers
|
||||
fun ChatDto.toDomain(): Chat = Chat(
|
||||
id = id,
|
||||
type = type,
|
||||
name = name ?: "Unknown Chat",
|
||||
avatar = null, // Update if avatar is added to ChatDto
|
||||
unreadCount = unreadCount,
|
||||
lastMessage = messages.firstOrNull()?.toDomain()
|
||||
)
|
||||
|
||||
fun MessageDto.toDomain(): Message = Message(
|
||||
id = id,
|
||||
chatId = chatId,
|
||||
senderId = senderId,
|
||||
senderName = sender.displayName,
|
||||
content = content,
|
||||
sequenceId = sequenceId,
|
||||
createdAt = createdAt,
|
||||
media = media.map { it.url },
|
||||
mediaUrl = media.firstOrNull()?.url,
|
||||
mediaType = when (media.firstOrNull()?.type) {
|
||||
"image" -> MediaType.IMAGE
|
||||
"video" -> MediaType.VIDEO
|
||||
"audio" -> MediaType.AUDIO
|
||||
"file" -> MediaType.FILE
|
||||
else -> MediaType.TEXT
|
||||
}
|
||||
)
|
||||
35
client-mobile/chats/di/ChatModule.kt
Normal file
35
client-mobile/chats/di/ChatModule.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package chats.di
|
||||
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.ChatRepositoryImpl
|
||||
import chats.domain.repository.ChatRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ChatModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatApi(retrofit: Retrofit): ChatApi {
|
||||
return retrofit.create(ChatApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatRepository(api: ChatApi): ChatRepository {
|
||||
return ChatRepositoryImpl(api)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatHubClient(): ChatHubClient {
|
||||
return ChatHubClient()
|
||||
}
|
||||
}
|
||||
10
client-mobile/chats/domain/model/ChatModels.kt
Normal file
10
client-mobile/chats/domain/model/ChatModels.kt
Normal file
@@ -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?
|
||||
)
|
||||
21
client-mobile/chats/domain/model/Message.kt
Normal file
21
client-mobile/chats/domain/model/Message.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package chats.domain.model
|
||||
|
||||
data class Message(
|
||||
val id: String,
|
||||
val chatId: String,
|
||||
val senderId: String,
|
||||
val senderName: String,
|
||||
val content: String?,
|
||||
val sequenceId: Int,
|
||||
val createdAt: String,
|
||||
val media: List<String> = emptyList(),
|
||||
val mediaUrl: String? = null,
|
||||
val mediaType: MediaType = MediaType.TEXT,
|
||||
val reactions: Map<String, Int> = emptyMap(),
|
||||
val isRead: Boolean = false,
|
||||
val replyTo: Message? = null
|
||||
)
|
||||
|
||||
enum class MediaType {
|
||||
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY
|
||||
}
|
||||
13
client-mobile/chats/domain/repository/ChatRepository.kt
Normal file
13
client-mobile/chats/domain/repository/ChatRepository.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package chats.domain.repository
|
||||
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
|
||||
interface ChatRepository {
|
||||
suspend fun getChats(): List<Chat>
|
||||
suspend fun getMessages(chatId: String): List<Message>
|
||||
suspend fun sendMessage(chatId: String, content: String): Message
|
||||
suspend fun addReaction(messageId: String, emoji: String)
|
||||
suspend fun sendTypingStatus(chatId: String)
|
||||
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
|
||||
}
|
||||
24
client-mobile/chats/domain/usecase/UploadMediaUseCase.kt
Normal file
24
client-mobile/chats/domain/usecase/UploadMediaUseCase.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt
Normal file
192
client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt
Normal file
@@ -0,0 +1,192 @@
|
||||
package chats.presentation.chat_detail
|
||||
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chats.presentation.components.EmojiPicker
|
||||
import chats.presentation.components.MessageBubble
|
||||
import core.utils.VoiceRecorder
|
||||
import core.utils.copyUriToFile
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatDetailScreen(
|
||||
chatName: String,
|
||||
viewModel: ChatDetailViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val voiceRecorder = remember { VoiceRecorder(context) }
|
||||
var textInput by remember { mutableStateOf("") }
|
||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
|
||||
// Пикер галереи
|
||||
val galleryLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
val file = copyUriToFile(context, it)
|
||||
file?.let { viewModel.uploadMedia(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Автопрокрутка к последнему сообщению
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(chatName, style = MaterialTheme.typography.titleMedium)
|
||||
if (state.isTyping) {
|
||||
Text(
|
||||
stringResource(R.string.typing),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.canCall) {
|
||||
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
|
||||
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
|
||||
}
|
||||
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
|
||||
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Список сообщений
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.messages) { message ->
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isCurrentUser = message.senderId == "CURRENT_USER_ID" // TODO: Get from Auth
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
|
||||
// Панель ввода
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) {
|
||||
Icon(
|
||||
Icons.Default.EmojiEmotions,
|
||||
contentDescription = stringResource(R.string.emoji),
|
||||
tint = if (isEmojiPickerVisible) MaterialTheme.colorScheme.primary else Color.Gray
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { galleryLauncher.launch("*/*") }) {
|
||||
Icon(Icons.Default.AttachFile, contentDescription = stringResource(R.string.attach))
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = textInput,
|
||||
onValueChange = { textInput = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
||||
maxLines = 4,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
if (textInput.isBlank()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!isRecording) {
|
||||
val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.mp3")
|
||||
voiceRecorder.startRecording(file)
|
||||
isRecording = true
|
||||
} else {
|
||||
voiceRecorder.stopRecording()
|
||||
isRecording = false
|
||||
// TODO: Send voice file
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
if (isRecording) Icons.Default.Stop else Icons.Default.Mic,
|
||||
contentDescription = stringResource(R.string.voice_message),
|
||||
tint = if (isRecording) Color.Red else Color.Gray
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
viewModel.sendMessage(textInput)
|
||||
textInput = ""
|
||||
}) {
|
||||
Icon(Icons.Default.Send, contentDescription = stringResource(R.string.send))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmojiPickerVisible) {
|
||||
EmojiPicker(onEmojiSelected = { textInput += it })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package chats.presentation.chat_detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.repository.ChatRepository
|
||||
import chats.data.repository.toDomain
|
||||
import core.network.ServerConfig
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ChatDetailState(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isTyping: Boolean = false,
|
||||
val typingUser: String? = null,
|
||||
val error: String? = null,
|
||||
val canCall: Boolean = true,
|
||||
val maxFileSize: Long = 100 * 1024 * 1024
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatDetailViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
private val signalrClient: ChatHubClient,
|
||||
private val serverConfig: ServerConfig
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ChatDetailState())
|
||||
val state: StateFlow<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 setChatId(chatId: String) {
|
||||
currentChatId = chatId
|
||||
loadMessages(chatId)
|
||||
observeSignalREvents()
|
||||
}
|
||||
|
||||
fun loadMessages(chatId: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true) }
|
||||
try {
|
||||
val messages = repository.getMessages(chatId)
|
||||
_state.update { it.copy(messages = messages, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSignalREvents() {
|
||||
signalrClient.events
|
||||
.filter { event ->
|
||||
when(event) {
|
||||
is ChatEvent.NewMessage -> event.message.chatId == currentChatId
|
||||
is ChatEvent.ReactionUpdated -> event.chatId == currentChatId
|
||||
is ChatEvent.UserTyping -> event.chatId == currentChatId
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> {
|
||||
// Avoid adding duplicates if already loaded
|
||||
_state.update { s ->
|
||||
val domainMsg = event.message.toDomain()
|
||||
if (s.messages.none { it.id == domainMsg.id }) {
|
||||
s.copy(messages = s.messages + domainMsg)
|
||||
} else s
|
||||
}
|
||||
}
|
||||
is ChatEvent.ReactionUpdated -> {
|
||||
updateMessageReaction(event.messageId, event.userId, event.emoji)
|
||||
}
|
||||
is ChatEvent.UserTyping -> {
|
||||
_state.update { it.copy(isTyping = true) }
|
||||
// Reset typing status after some delay would be better,
|
||||
// but usually server sends stopped_typing event.
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||
_state.update { s ->
|
||||
val updatedMessages = s.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
// Logic to update reactions map.
|
||||
// Note: Simplified logic, usually we need to know if it was added or removed.
|
||||
// If we assume reaction_updated is a toggle:
|
||||
val currentReactions = msg.reactions.toMutableMap()
|
||||
val count = currentReactions[emoji] ?: 0
|
||||
// This is a placeholder logic as the exact behavior depends on server implementation.
|
||||
// For now, let's just increment/decrement based on some convention or just refresh.
|
||||
currentReactions[emoji] = count + 1
|
||||
msg.copy(reactions = currentReactions)
|
||||
} else msg
|
||||
}
|
||||
s.copy(messages = updatedMessages)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.sendMessage(chatId, text)
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.localizedMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(messageId: String, emoji: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.addReaction(messageId, emoji)
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadMedia(file: File) {
|
||||
if (file.length() > _state.value.maxFileSize) {
|
||||
_state.update { it.copy(error = "File too large") }
|
||||
return
|
||||
}
|
||||
// Upload logic...
|
||||
}
|
||||
}
|
||||
120
client-mobile/chats/presentation/chat_list/ChatListScreen.kt
Normal file
120
client-mobile/chats/presentation/chat_list/ChatListScreen.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package chats.presentation.chat_list
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chats.presentation.components.ChatItem
|
||||
import stories.presentation.StoryViewModel
|
||||
import stories.presentation.components.StoryThumbnail
|
||||
import ru.knot.messager.R
|
||||
|
||||
@Composable
|
||||
fun HorizontalDividerComponent(
|
||||
modifier: Modifier = Modifier,
|
||||
thickness: androidx.compose.ui.unit.Dp = 1.dp,
|
||||
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
|
||||
) {
|
||||
androidx.compose.material3.Divider(
|
||||
modifier = modifier,
|
||||
thickness = thickness,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatListScreen(
|
||||
viewModel: ChatListViewModel,
|
||||
storyViewModel: StoryViewModel,
|
||||
onChatClick: (String) -> Unit,
|
||||
onStoryClick: (Int) -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val storyState by storyViewModel.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.chats_title)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (state.isLoading && state.chats.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
LazyColumn {
|
||||
// Ряд историй сверху списка чатов
|
||||
item {
|
||||
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
) {
|
||||
items(storyState.storyGroups.size) { index ->
|
||||
val group = storyState.storyGroups[index]
|
||||
StoryThumbnail(
|
||||
username = group.username,
|
||||
avatarUrl = group.avatar,
|
||||
hasUnseen = true, // В идеале проверяем по статусам
|
||||
onClick = { onStoryClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDividerComponent(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null && state.chats.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
} else if (state.chats.isEmpty() && !state.isLoading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillParentMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_chats_found),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(state.chats) { chat ->
|
||||
ChatItem(chat = chat, onClick = onChatClick)
|
||||
HorizontalDividerComponent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package chats.presentation.chat_list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.repository.ChatRepository
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import core.network.ServerConfig
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import chats.data.repository.toDomain
|
||||
|
||||
data class ChatListState(
|
||||
val chats: List<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
|
||||
) : 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) }
|
||||
loadChats()
|
||||
observeSignalREvents()
|
||||
}
|
||||
|
||||
fun loadChats() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true) }
|
||||
try {
|
||||
val chats = repository.getChats()
|
||||
_state.update { it.copy(chats = chats, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSignalREvents() {
|
||||
signalrClient.events
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> {
|
||||
updateChatsWithNewMessage(event)
|
||||
}
|
||||
is ChatEvent.NewChat -> {
|
||||
_state.update { it.copy(chats = listOf(event.chat.toDomain()) + it.chats) }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
|
||||
_state.update { currentState ->
|
||||
val updatedChats = currentState.chats.map { chat ->
|
||||
if (chat.id == event.message.chatId) {
|
||||
chat.copy(
|
||||
lastMessage = event.message.toDomain(),
|
||||
unreadCount = chat.unreadCount + 1
|
||||
)
|
||||
} else chat
|
||||
}.sortedByDescending { it.lastMessage?.createdAt }
|
||||
|
||||
currentState.copy(chats = updatedChats)
|
||||
}
|
||||
}
|
||||
}
|
||||
100
client-mobile/chats/presentation/components/ChatItem.kt
Normal file
100
client-mobile/chats/presentation/components/ChatItem.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chats.domain.model.Chat
|
||||
|
||||
@Composable
|
||||
fun ChatItem(
|
||||
chat: Chat,
|
||||
onClick: (String) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick(chat.id) }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Заглушка аватара (в реальном приложении используем Coil для URL)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = chat.name.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = chat.name,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
chat.lastMessage?.let {
|
||||
Text(
|
||||
text = it.createdAt.takeLast(5), // Упрощенный формат времени
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = chat.lastMessage?.content ?: "No messages yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (chat.unreadCount > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = chat.unreadCount.toString(),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
client-mobile/chats/presentation/components/EmojiPicker.kt
Normal file
39
client-mobile/chats/presentation/components/EmojiPicker.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
client-mobile/chats/presentation/components/KlipyPicker.kt
Normal file
84
client-mobile/chats/presentation/components/KlipyPicker.kt
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
client-mobile/chats/presentation/components/LinkPreview.kt
Normal file
78
client-mobile/chats/presentation/components/LinkPreview.kt
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
208
client-mobile/chats/presentation/components/MessageBubble.kt
Normal file
208
client-mobile/chats/presentation/components/MessageBubble.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.model.MediaType
|
||||
import coil.compose.AsyncImage
|
||||
import core.presentation.components.AppVideoPlayer
|
||||
import core.presentation.components.AppAudioPlayer
|
||||
|
||||
import chats.presentation.components.LinkPreview
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: Message,
|
||||
isCurrentUser: Boolean,
|
||||
onReactionClick: (String) -> Unit = {}
|
||||
) {
|
||||
var showReactionPicker by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundColor = if (isCurrentUser) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
val contentColor = if (isCurrentUser) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val shape = if (isCurrentUser) {
|
||||
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
||||
} else {
|
||||
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||
}
|
||||
|
||||
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
|
||||
(message.content == null || message.content.isEmpty())
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
contentAlignment = alignment
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = { /* Handle normal click */ },
|
||||
onLongClick = { showReactionPicker = true }
|
||||
)
|
||||
.padding(8.dp)
|
||||
.widthIn(max = 300.dp)
|
||||
) {
|
||||
if (showReactionPicker) {
|
||||
Popup(
|
||||
alignment = Alignment.TopCenter,
|
||||
offset = IntOffset(0, -100),
|
||||
onDismissRequest = { showReactionPicker = false }
|
||||
) {
|
||||
ReactionPicker(onReactionSelected = {
|
||||
onReactionClick(it)
|
||||
showReactionPicker = false
|
||||
})
|
||||
}
|
||||
}
|
||||
// Sender Name (only for others)
|
||||
if (!isCurrentUser) {
|
||||
Text(
|
||||
text = message.senderName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Reply Info
|
||||
message.replyTo?.let { reply ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = reply.senderName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = reply.content ?: "[Media]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Media Content
|
||||
if (message.mediaUrl != null) {
|
||||
when (message.mediaType) {
|
||||
MediaType.IMAGE -> {
|
||||
AsyncImage(
|
||||
model = message.mediaUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
MediaType.VIDEO -> {
|
||||
AppVideoPlayer(
|
||||
url = message.mediaUrl,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
useController = true,
|
||||
autoPlay = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
MediaType.AUDIO -> {
|
||||
AppAudioPlayer(
|
||||
url = message.mediaUrl,
|
||||
isVoiceMessage = isVoiceMessage,
|
||||
contentColor = contentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Text Content (Story reply or simple text)
|
||||
if (message.mediaType == MediaType.STORY_REPLY) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text("Story Reply: ${message.content}", color = contentColor, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
} else if (!message.content.isNullOrBlank() && !isVoiceMessage) {
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = contentColor
|
||||
)
|
||||
}
|
||||
|
||||
// Time and Status
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = message.createdAt.takeLast(5),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
// Reactions
|
||||
if (message.reactions.isNotEmpty()) {
|
||||
MessageReactions(
|
||||
reactions = message.reactions,
|
||||
onReactionClick = onReactionClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
client-mobile/contacts/data/remote/api/ContactApi.kt
Normal file
26
client-mobile/contacts/data/remote/api/ContactApi.kt
Normal file
@@ -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?
|
||||
)
|
||||
130
client-mobile/contacts/presentation/ContactListScreen.kt
Normal file
130
client-mobile/contacts/presentation/ContactListScreen.kt
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
client-mobile/contacts/presentation/ContactListViewModel.kt
Normal file
26
client-mobile/contacts/presentation/ContactListViewModel.kt
Normal file
@@ -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) {
|
||||
// Заглушка поиска
|
||||
}
|
||||
}
|
||||
27
client-mobile/core/di/AppModule.kt
Normal file
27
client-mobile/core/di/AppModule.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
45
client-mobile/core/di/NetworkModule.kt
Normal file
45
client-mobile/core/di/NetworkModule.kt
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
20
client-mobile/core/domain/model/ServerConfigModel.kt
Normal file
20
client-mobile/core/domain/model/ServerConfigModel.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package core.domain.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class ServerConfigModel(
|
||||
@SerializedName("features") val features: FeaturesConfig = FeaturesConfig(),
|
||||
@SerializedName("limits") val limits: LimitsConfig = LimitsConfig()
|
||||
)
|
||||
|
||||
data class FeaturesConfig(
|
||||
@SerializedName("stories") val stories: Boolean = true,
|
||||
@SerializedName("polls") val polls: Boolean = true,
|
||||
@SerializedName("calls") val calls: Boolean = true,
|
||||
@SerializedName("groups") val groups: Boolean = true
|
||||
)
|
||||
|
||||
data class LimitsConfig(
|
||||
@SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024, // 100MB default
|
||||
@SerializedName("maxGroupMembers") val maxGroupMembers: Int = 200
|
||||
)
|
||||
22
client-mobile/core/network/AuthInterceptor.kt
Normal file
22
client-mobile/core/network/AuthInterceptor.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
46
client-mobile/core/network/DynamicBaseUrlInterceptor.kt
Normal file
46
client-mobile/core/network/DynamicBaseUrlInterceptor.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
49
client-mobile/core/network/ServerConfig.kt
Normal file
49
client-mobile/core/network/ServerConfig.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package core.network
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import core.domain.model.FeaturesConfig
|
||||
import core.domain.model.ServerConfigModel
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ServerConfig @Inject constructor(
|
||||
private val context: Context,
|
||||
private val gson: Gson
|
||||
) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("server_settings", Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_BASE_URL = "https://your-api.com/api/"
|
||||
private const val KEY_BASE_URL = "base_url"
|
||||
private const val KEY_CONFIG_DATA = "config_data"
|
||||
}
|
||||
|
||||
fun getBaseUrl(): String {
|
||||
return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL
|
||||
}
|
||||
|
||||
fun setBaseUrl(url: String) {
|
||||
val formattedUrl = if (url.endsWith("/")) url else "$url/"
|
||||
prefs.edit().putString(KEY_BASE_URL, formattedUrl).apply()
|
||||
}
|
||||
|
||||
fun saveServerConfig(config: ServerConfigModel) {
|
||||
prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).apply()
|
||||
}
|
||||
|
||||
fun getServerConfig(): ServerConfigModel {
|
||||
val json = prefs.getString(KEY_CONFIG_DATA, null) ?: return ServerConfigModel()
|
||||
return try {
|
||||
gson.fromJson(json, ServerConfigModel::class.java)
|
||||
} catch (e: Exception) {
|
||||
ServerConfigModel()
|
||||
}
|
||||
}
|
||||
|
||||
fun isFeatureEnabled(feature: (FeaturesConfig) -> Boolean): Boolean {
|
||||
return feature(getServerConfig().features)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package core.notifications.data
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import auth.data.remote.api.AuthApi
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
@Inject
|
||||
lateinit var authApi: AuthApi
|
||||
|
||||
private val job = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
// Отправляем новый токен на сервер
|
||||
scope.launch {
|
||||
try {
|
||||
authApi.updatePushToken(token)
|
||||
} catch (e: Exception) {
|
||||
// Ошибка логируется или игнорируется (сервер может быть недоступен)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
|
||||
val title = message.notification?.title ?: message.data["title"]
|
||||
val body = message.notification?.body ?: message.data["body"]
|
||||
val type = message.data["type"] // "chat", "call", "story"
|
||||
|
||||
showNotification(title, body, type)
|
||||
}
|
||||
|
||||
private fun showNotification(title: String?, body: String?, type: String?) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channelId = "fork_notifications_channel"
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"Fork Messenger Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info) // Заменить на наш Indigo-логотип
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
job.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
151
client-mobile/core/presentation/components/AppAudioPlayer.kt
Normal file
151
client-mobile/core/presentation/components/AppAudioPlayer.kt
Normal file
@@ -0,0 +1,151 @@
|
||||
package core.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun AppAudioPlayer(
|
||||
url: String,
|
||||
isVoiceMessage: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
setMediaItem(mediaItem)
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var currentPosition by remember { mutableLongStateOf(0L) }
|
||||
var duration by remember { mutableLongStateOf(0L) }
|
||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_READY) {
|
||||
duration = exoPlayer.duration
|
||||
}
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
exoPlayer.removeListener(listener)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление прогресса
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
currentPosition = exoPlayer.currentPosition
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isPlaying) exoPlayer.pause() else exoPlayer.play()
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
|
||||
Slider(
|
||||
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
|
||||
onValueChange = {
|
||||
val newPos = (it * duration).toLong()
|
||||
exoPlayer.seekTo(newPos)
|
||||
currentPosition = newPos
|
||||
},
|
||||
modifier = Modifier.height(24.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = contentColor,
|
||||
activeTrackColor = contentColor,
|
||||
inactiveTrackColor = contentColor.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = formatDuration(currentPosition),
|
||||
fontSize = 10.sp,
|
||||
color = contentColor.copy(alpha = 0.7f)
|
||||
)
|
||||
Text(
|
||||
text = formatDuration(duration),
|
||||
fontSize = 10.sp,
|
||||
color = contentColor.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVoiceMessage) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
playbackSpeed = when (playbackSpeed) {
|
||||
1.0f -> 1.5f
|
||||
1.5f -> 2.0f
|
||||
else -> 1.0f
|
||||
}
|
||||
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(40.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${playbackSpeed}x",
|
||||
fontSize = 12.sp,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(durationMs: Long): String {
|
||||
val totalSeconds = durationMs / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return String.format("%02d:%02d", minutes, seconds)
|
||||
}
|
||||
50
client-mobile/core/presentation/components/AppAvatar.kt
Normal file
50
client-mobile/core/presentation/components/AppAvatar.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
package core.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import core.presentation.theme.SoftSquareShape
|
||||
|
||||
@Composable
|
||||
fun AppAvatar(
|
||||
url: String?,
|
||||
name: String,
|
||||
size: Dp = 48.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(SoftSquareShape) // Тот самый "мягкий квадрат"
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (url != null) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = name,
|
||||
modifier = Modifier.size(size),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Заглушка, если нет аватара (первая буква имени)
|
||||
Text(
|
||||
text = name.take(1).uppercase(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = (size.value * 0.4).sp,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
client-mobile/core/presentation/components/AppVideoPlayer.kt
Normal file
61
client-mobile/core/presentation/components/AppVideoPlayer.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package core.presentation.components
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun AppVideoPlayer(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
useController: Boolean = true,
|
||||
autoPlay: Boolean = true,
|
||||
onVideoFinished: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
setMediaItem(mediaItem)
|
||||
prepare()
|
||||
playWhenReady = autoPlay
|
||||
addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED) {
|
||||
onVideoFinished()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка ресурсов при выходе
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = {
|
||||
PlayerView(it).apply {
|
||||
player = exoPlayer
|
||||
this.useController = useController
|
||||
// Для сторис используем заполнение экрана
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
155
client-mobile/core/presentation/components/GlassNavigationBar.kt
Normal file
155
client-mobile/core/presentation/components/GlassNavigationBar.kt
Normal file
@@ -0,0 +1,155 @@
|
||||
package core.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.knot.messager.R
|
||||
|
||||
@Composable
|
||||
fun GlassNavigationBar(
|
||||
currentRoute: String,
|
||||
onNavigate: (String) -> Unit,
|
||||
userAvatarUrl: String? = null,
|
||||
username: String = ""
|
||||
) {
|
||||
// Контейнер с эффектом стекла
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.height(72.dp)
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.2f),
|
||||
Color.Transparent
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(32.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NavItem(
|
||||
icon = Icons.Default.ChatBubble,
|
||||
label = stringResource(R.string.chats),
|
||||
isSelected = currentRoute == "chat_list",
|
||||
onClick = { onNavigate("chat_list") }
|
||||
)
|
||||
NavItem(
|
||||
icon = Icons.Default.People,
|
||||
label = stringResource(R.string.contacts_tab),
|
||||
isSelected = currentRoute == "contacts",
|
||||
onClick = { onNavigate("contacts") }
|
||||
)
|
||||
NavItem(
|
||||
icon = Icons.Default.Settings,
|
||||
label = stringResource(R.string.settings),
|
||||
isSelected = currentRoute == "settings",
|
||||
onClick = { onNavigate("settings") }
|
||||
)
|
||||
// Профиль с аватаром
|
||||
ProfileNavItem(
|
||||
avatarUrl = userAvatarUrl,
|
||||
username = username,
|
||||
isSelected = currentRoute.startsWith("profile"),
|
||||
onClick = { onNavigate("profile") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val contentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = contentColor, modifier = Modifier.size(24.dp))
|
||||
Text(text = label, color = contentColor, fontSize = 10.sp, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNavItem(
|
||||
avatarUrl: String?,
|
||||
username: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.border(2.dp, borderColor, CircleShape)
|
||||
.padding(2.dp)
|
||||
) {
|
||||
AppAvatar(
|
||||
url = avatarUrl,
|
||||
name = username,
|
||||
size = 24.dp
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.profile_tab),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
102
client-mobile/core/presentation/settings/SettingsScreen.kt
Normal file
102
client-mobile/core/presentation/settings/SettingsScreen.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package core.presentation.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
|
||||
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.server_connection),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { baseUrl = it },
|
||||
label = { Text(stringResource(R.string.api_base_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("https://example.com/api/") }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.server_features),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
FeatureStatusItem(stringResource(R.string.stories), state.config.features.stories)
|
||||
FeatureStatusItem(stringResource(R.string.polls), state.config.features.polls)
|
||||
FeatureStatusItem(stringResource(R.string.calls), state.config.features.calls)
|
||||
FeatureStatusItem(stringResource(R.string.groups), state.config.features.groups)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.limits),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text("${stringResource(R.string.max_file_size)}: ${state.config.limits.maxFileSize / (1024 * 1024)} MB")
|
||||
Text("${stringResource(R.string.max_group_members)}: ${state.config.limits.maxGroupMembers}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeatureStatusItem(name: String, enabled: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(name)
|
||||
Text(
|
||||
text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
|
||||
color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package core.presentation.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import core.domain.model.ServerConfigModel
|
||||
import core.network.ServerConfig
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsState(
|
||||
val baseUrl: String = "",
|
||||
val config: ServerConfigModel = ServerConfigModel()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val serverConfig: ServerConfig
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SettingsState())
|
||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
_state.update {
|
||||
it.copy(
|
||||
baseUrl = serverConfig.getBaseUrl(),
|
||||
config = serverConfig.getServerConfig()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveBaseUrl(url: String) {
|
||||
serverConfig.setBaseUrl(url)
|
||||
_state.update { it.copy(baseUrl = url) }
|
||||
}
|
||||
}
|
||||
41
client-mobile/core/presentation/theme/Theme.kt
Normal file
41
client-mobile/core/presentation/theme/Theme.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package core.presentation.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// Цвета из веб-версии (Dark Mode)
|
||||
val DarkBackground = Color(0xFF0F0F10)
|
||||
val SurfaceContainer = Color(0xFF161618)
|
||||
val Primary = Color(0xFF6366F1) // Indigo/Primary из Tailwind
|
||||
val PrimaryIndigo = Primary
|
||||
val Indigo500 = Primary
|
||||
val OnSurfaceVariant = Color(0xFF94A3B8)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Primary,
|
||||
background = DarkBackground,
|
||||
surface = SurfaceContainer,
|
||||
onPrimary = Color.White,
|
||||
onBackground = Color.White,
|
||||
onSurface = Color.White,
|
||||
onSurfaceVariant = OnSurfaceVariant
|
||||
)
|
||||
|
||||
val SoftSquareShape = RoundedCornerShape(24.dp) // "Мягкий квадрат"
|
||||
|
||||
@Composable
|
||||
fun ForkMessengerTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DarkColors,
|
||||
shapes = MaterialTheme.shapes.copy(
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = SoftSquareShape,
|
||||
large = RoundedCornerShape(32.dp)
|
||||
),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
34
client-mobile/core/security/TokenManager.kt
Normal file
34
client-mobile/core/security/TokenManager.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package core.security
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TokenManager @Inject constructor(context: Context) {
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"auth_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun saveToken(token: String) {
|
||||
prefs.edit().putString("jwt_token", token).apply()
|
||||
}
|
||||
|
||||
fun getToken(): String? {
|
||||
return prefs.getString("jwt_token", null)
|
||||
}
|
||||
|
||||
fun deleteToken() {
|
||||
prefs.edit().remove("jwt_token").apply()
|
||||
}
|
||||
}
|
||||
23
client-mobile/core/utils/FileUtils.kt
Normal file
23
client-mobile/core/utils/FileUtils.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package core.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
fun copyUriToFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val file = File(context.cacheDir, "${UUID.randomUUID()}.tmp")
|
||||
val outputStream = FileOutputStream(file)
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user