Приложение
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Tue Apr 14 00:27:15 MSK 2026
|
||||
gradle.version=8.5
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Tue Apr 14 00:13:58 MSK 2026
|
||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "483917912506",
|
||||
"project_id": "knot-bad1a",
|
||||
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
||||
"android_client_info": {
|
||||
"package_name": "ru.knot.messager"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.knot.messager">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name="com.knot.messenger.MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KnotMessenger">
|
||||
|
||||
<activity
|
||||
android:name="com.knot.messenger.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.KnotMessenger">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="core.notifications.data.ForkFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,56 @@
|
||||
<resources>
|
||||
<string name="app_name">ForkMessenger</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="register">Register</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="server_connection">Server Connection</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Server Features</string>
|
||||
<string name="stories">Stories</string>
|
||||
<string name="polls">Polls</string>
|
||||
<string name="calls">Calls</string>
|
||||
<string name="groups">Groups</string>
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="limits">Limits</string>
|
||||
<string name="max_file_size">Max File Size</string>
|
||||
<string name="max_group_members">Max Group Members</string>
|
||||
<string name="message">Message</string>
|
||||
<string name="call">Call</string>
|
||||
<string name="block">Block</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="confirm_password">Confirm Password</string>
|
||||
<string name="passwords_not_match">Passwords do not match</string>
|
||||
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||
<string name="already_have_account">Already have an account? Login</string>
|
||||
<string name="error_occurred">An error occurred</string>
|
||||
<string name="loading">Loading...</string>
|
||||
<string name="chats_title">Chats</string>
|
||||
<string name="contacts_title">Contacts</string>
|
||||
<string name="stories_title">Stories</string>
|
||||
<string name="create_story">Create Story</string>
|
||||
<string name="send_message_hint">Type a message...</string>
|
||||
<string name="reply_to_user">Reply to %1$s...</string>
|
||||
<string name="story_editor">STORY EDITOR</string>
|
||||
<string name="publish">PUBLISH</string>
|
||||
<string name="start_creation">START CREATION</string>
|
||||
<string name="text_tool">TEXT</string>
|
||||
<string name="crop_tool">CROP</string>
|
||||
<string name="stickers_tool">STICKERS</string>
|
||||
<string name="brush_tool">BRUSH</string>
|
||||
<string name="filters_tool">FILTERS</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="no_chats_found">No chats found</string>
|
||||
<string name="typing">typing...</string>
|
||||
<string name="video_call">Video Call</string>
|
||||
<string name="emoji">Emoji</string>
|
||||
<string name="attach">Attach</string>
|
||||
<string name="message_placeholder">Message...</string>
|
||||
<string name="voice_message">Voice Message</string>
|
||||
<string name="send">Send</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,77 @@
|
||||
<resources>
|
||||
<string name="app_name">ForkMessenger</string>
|
||||
<string name="login">Войти</string>
|
||||
<string name="register">Регистрация</string>
|
||||
<string name="username">Имя пользователя</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="display_name">Отображаемое имя</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="server_connection">Подключение к серверу</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Функции сервера</string>
|
||||
<string name="stories">Истории</string>
|
||||
<string name="polls">Опросы</string>
|
||||
<string name="calls">Звонки</string>
|
||||
<string name="groups">Группы</string>
|
||||
<string name="enabled">Включено</string>
|
||||
<string name="disabled">Отключено</string>
|
||||
<string name="limits">Лимиты</string>
|
||||
<string name="max_file_size">Макс. размер файла</string>
|
||||
<string name="max_group_members">Макс. участников в группе</string>
|
||||
<string name="message">Сообщение</string>
|
||||
<string name="call">Позвонить</string>
|
||||
<string name="block">Заблокировать</string>
|
||||
<string name="profile">Профиль</string>
|
||||
<string name="confirm_password">Подтвердите пароль</string>
|
||||
<string name="passwords_not_match">Пароли не совпадают</string>
|
||||
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
|
||||
<string name="already_have_account">Уже есть аккаунт? Войти</string>
|
||||
<string name="error_occurred">Произошла ошибка</string>
|
||||
<string name="loading">Загрузка...</string>
|
||||
<string name="chats_title">Чаты</string>
|
||||
<string name="contacts_title">Контакты</string>
|
||||
<string name="stories_title">Истории</string>
|
||||
<string name="create_story">Создать историю</string>
|
||||
<string name="send_message_hint">Напишите сообщение...</string>
|
||||
<string name="reply_to_user">Ответить %1$s...</string>
|
||||
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
|
||||
<string name="publish">ОПУБЛИКОВАТЬ</string>
|
||||
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
|
||||
<string name="text_tool">ТЕКСТ</string>
|
||||
<string name="crop_tool">ОБРЕЗКА</string>
|
||||
<string name="stickers_tool">СТИКЕРЫ</string>
|
||||
<string name="brush_tool">КИСТЬ</string>
|
||||
<string name="filters_tool">ФИЛЬТРЫ</string>
|
||||
<string name="remove">Удалить</string>
|
||||
<string name="no_chats_found">Чаты не найдены</string>
|
||||
<string name="typing">печатает...</string>
|
||||
<string name="video_call">Видеозвонок</string>
|
||||
<string name="emoji">Эмодзи</string>
|
||||
<string name="attach">Прикрепить</string>
|
||||
<string name="message_placeholder">Сообщение...</string>
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="send">Отправить</string>
|
||||
<string name="search_hint">Поиск...</string>
|
||||
<string name="online">В сети</string>
|
||||
<string name="last_seen">Был(а): %1$s</string>
|
||||
<string name="last_seen_recently">недавно</string>
|
||||
<string name="all">Все</string>
|
||||
<string name="online_tab">Онлайн</string>
|
||||
<string name="blocked">Заблокированные</string>
|
||||
<string name="media">Медиа</string>
|
||||
<string name="notifications">Уведомления</string>
|
||||
<string name="mute">Без звука</string>
|
||||
<string name="unmute">Включить звук</string>
|
||||
<string name="log_out">Выйти из аккаунта</string>
|
||||
<string name="bio">О себе</string>
|
||||
<string name="edit_profile">Редактировать профиль</string>
|
||||
<string name="username_label">Имя пользователя</string>
|
||||
<string name="change_photo">Изменить фото</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="crop">Обрезать</string>
|
||||
<string name="chats">Чаты</string>
|
||||
<string name="contacts_tab">Контакты</string>
|
||||
<string name="profile_tab">Профиль</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">#0F0F10</item>
|
||||
<item name="android:windowBackground">#0F0F10</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package auth.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AuthRequest(
|
||||
@SerializedName("userName") val userName: String,
|
||||
@SerializedName("password") val password: String
|
||||
)
|
||||
|
||||
data class AuthResponse(
|
||||
@SerializedName("accessToken") val accessToken: String?,
|
||||
@SerializedName("user") val user: UserDto?,
|
||||
@SerializedName("userId") val userId: String?,
|
||||
@SerializedName("username") val username: String?,
|
||||
@SerializedName("displayName") val displayName: String?
|
||||
)
|
||||
|
||||
data class UserDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("userName") val userName: String,
|
||||
@SerializedName("displayName") val displayName: String?,
|
||||
@SerializedName("avatarUrl") val avatarUrl: String?
|
||||
)
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package auth.di
|
||||
|
||||
import auth.data.remote.api.AuthApi
|
||||
import auth.data.repository.AuthRepositoryImpl
|
||||
import auth.domain.repository.AuthRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
||||
return retrofit.create(AuthApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(
|
||||
api: AuthApi,
|
||||
tokenManager: TokenManager,
|
||||
serverConfig: ServerConfig
|
||||
): AuthRepository {
|
||||
return AuthRepositoryImpl(api, tokenManager, serverConfig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth.domain.model
|
||||
|
||||
data class AuthResult(
|
||||
val token: String,
|
||||
val userId: String,
|
||||
val userName: String,
|
||||
val displayName: String,
|
||||
val avatarUrl: String?
|
||||
)
|
||||
@@ -0,0 +1,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>
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onNavigateToRegister: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onLoginSuccess: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var userName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(state.isAuthenticated) {
|
||||
if (state.isAuthenticated) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.login)) },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = userName,
|
||||
onValueChange = { userName = it },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.login(userName, password) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = userName.isNotBlank() && password.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.login))
|
||||
}
|
||||
TextButton(onClick = onNavigateToRegister) {
|
||||
Text(stringResource(R.string.no_account_register))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package auth.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
onRegisterSuccess: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var userName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
|
||||
|
||||
LaunchedEffect(state.isAuthenticated) {
|
||||
if (state.isAuthenticated) {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text(stringResource(R.string.register)) })
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = userName,
|
||||
onValueChange = { userName = it },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text(stringResource(R.string.confirm_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
if (password == confirmPassword) {
|
||||
errorMessage = null
|
||||
viewModel.register(userName, password)
|
||||
} else {
|
||||
errorMessage = passwordsNotMatchMsg
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.register))
|
||||
}
|
||||
TextButton(onClick = onNavigateToLogin) {
|
||||
Text(stringResource(R.string.already_have_account))
|
||||
}
|
||||
}
|
||||
|
||||
val displayError = state.error ?: errorMessage
|
||||
if (displayError != null) {
|
||||
Text(
|
||||
text = displayError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("com.android.library") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.48" apply false
|
||||
id("com.google.gms.google-services") version "4.4.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package calls.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
|
||||
class CallAudioManager(private val context: Context) {
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private var originalMode: Int = AudioManager.MODE_NORMAL
|
||||
private var originalIsSpeakerphoneOn: Boolean = false
|
||||
|
||||
fun startCallMode(isVideoCall: Boolean) {
|
||||
originalMode = audioManager.mode
|
||||
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
|
||||
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
setSpeakerphoneOn(isVideoCall)
|
||||
|
||||
// Запрашиваем фокус аудио
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val playbackAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||
.setAudioAttributes(playbackAttributes)
|
||||
.build()
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeakerphoneOn(on: Boolean) {
|
||||
audioManager.isSpeakerphoneOn = on
|
||||
}
|
||||
|
||||
fun stopCallMode() {
|
||||
audioManager.mode = originalMode
|
||||
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
|
||||
audioManager.abandonAudioFocus(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package calls.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import org.webrtc.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GroupWebRtcManager @Inject constructor(private val context: Context) {
|
||||
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
|
||||
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||
|
||||
private fun createFactory(): PeerConnectionFactory {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||
)
|
||||
return PeerConnectionFactory.builder()
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
|
||||
val iceServers = listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||
)
|
||||
val pc = factory.createPeerConnection(iceServers, observer)
|
||||
if (pc != null) {
|
||||
peerConnections[userId] = pc
|
||||
}
|
||||
return pc
|
||||
}
|
||||
|
||||
fun removeParticipant(userId: String) {
|
||||
peerConnections[userId]?.dispose()
|
||||
peerConnections.remove(userId)
|
||||
}
|
||||
|
||||
fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
|
||||
|
||||
fun closeAll() {
|
||||
peerConnections.values.forEach { it.dispose() }
|
||||
peerConnections.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package calls.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WebRtcManager @Inject constructor(private val context: Context) {
|
||||
private var peerConnection: PeerConnection? = null
|
||||
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||
|
||||
// Аудио и видео источники
|
||||
private val videoSource by lazy { factory.createVideoSource(false) }
|
||||
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
|
||||
|
||||
private fun createFactory(): PeerConnectionFactory {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||
)
|
||||
return PeerConnectionFactory.builder()
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun initializePeerConnection(observer: PeerConnection.Observer) {
|
||||
val iceServers = listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||
)
|
||||
peerConnection = factory.createPeerConnection(iceServers, observer)
|
||||
}
|
||||
|
||||
fun createOffer(observer: SdpObserver) {
|
||||
peerConnection?.createOffer(observer, MediaConstraints())
|
||||
}
|
||||
|
||||
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
|
||||
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
|
||||
}
|
||||
|
||||
fun addIceCandidate(candidate: IceCandidate) {
|
||||
peerConnection?.addIceCandidate(candidate)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
peerConnection?.dispose()
|
||||
peerConnection = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package calls.presentation
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import core.presentation.components.AppAvatar
|
||||
import ru.knot.messager.R
|
||||
|
||||
@Composable
|
||||
fun CallScreen(
|
||||
viewModel: CallViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF0F0F10),
|
||||
Color(0xFF161618),
|
||||
Color(0xFF6366F1).copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Контент звонка (Аватар или Видео)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 100.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AppAvatar(
|
||||
url = state.callerAvatar,
|
||||
name = state.callerName,
|
||||
size = 140.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = state.callerName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = when (state.status) {
|
||||
CallStatus.INCOMING -> "Входящий звонок..."
|
||||
CallStatus.OUTGOING -> "Вызов..."
|
||||
CallStatus.CONNECTED -> "00:00" // TODO: Timer
|
||||
CallStatus.ENDED -> "Звонок завершен"
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопки управления (Внизу)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 60.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (state.status == CallStatus.INCOMING) {
|
||||
// Кнопка отклонить
|
||||
CallActionButton(
|
||||
icon = Icons.Default.CallEnd,
|
||||
backgroundColor = Color.Red,
|
||||
onClick = { viewModel.endCall(); onBack() }
|
||||
)
|
||||
// Кнопка принять
|
||||
CallActionButton(
|
||||
icon = Icons.Default.Call,
|
||||
backgroundColor = Color(0xFF10B981), // Green
|
||||
onClick = { viewModel.acceptCall() }
|
||||
)
|
||||
} else {
|
||||
// Стандартные кнопки во время разговора
|
||||
IconButton(
|
||||
onClick = { /* viewModel.toggleMic() */ },
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
|
||||
CallActionButton(
|
||||
icon = Icons.Default.CallEnd,
|
||||
backgroundColor = Color.Red,
|
||||
onClick = { viewModel.endCall(); onBack() }
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { /* viewModel.toggleSpeaker() */ },
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
backgroundColor: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
containerColor = backgroundColor,
|
||||
contentColor = Color.White,
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(64.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package calls.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import calls.data.remote.WebRtcManager
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
|
||||
|
||||
data class CallState(
|
||||
val status: CallStatus = CallStatus.IDLE,
|
||||
val chatId: String? = null,
|
||||
val callerName: String = "",
|
||||
val callerAvatar: String? = null,
|
||||
val isMuted: Boolean = false,
|
||||
val isSpeakerOn: Boolean = false,
|
||||
val localVideoTrack: VideoTrack? = null,
|
||||
val remoteVideoTrack: VideoTrack? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CallViewModel @Inject constructor(
|
||||
private val webRtcManager: WebRtcManager,
|
||||
private val signalrClient: ChatHubClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CallState())
|
||||
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
observeSignaling()
|
||||
}
|
||||
|
||||
private fun observeSignaling() {
|
||||
signalrClient.events
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.CallIncoming -> onIncomingCall(event)
|
||||
is ChatEvent.CallAnswered -> onCallAnswered(event)
|
||||
is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
|
||||
is ChatEvent.CallEnded -> onCallEnded()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun onIncomingCall(event: ChatEvent.CallIncoming) {
|
||||
_state.update { it.copy(
|
||||
status = CallStatus.INCOMING,
|
||||
chatId = event.chatId,
|
||||
callerName = "User ${event.from}" // TODO: Load actual user info
|
||||
) }
|
||||
// Set remote description from offer
|
||||
webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onSetSuccess() {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun onCallAnswered(event: ChatEvent.CallAnswered) {
|
||||
_state.update { it.copy(status = CallStatus.CONNECTED) }
|
||||
webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onSetSuccess() {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
|
||||
// Parse candidate JSON and add to peer connection
|
||||
// webRtcManager.addIceCandidate(...)
|
||||
}
|
||||
|
||||
private fun onCallEnded() {
|
||||
_state.update { it.copy(status = CallStatus.ENDED) }
|
||||
webRtcManager.close()
|
||||
}
|
||||
|
||||
fun acceptCall() {
|
||||
val chatId = _state.value.chatId ?: return
|
||||
// Create answer and send via SignalR
|
||||
}
|
||||
|
||||
fun endCall() {
|
||||
val chatId = _state.value.chatId ?: return
|
||||
// Send call_end via SignalR
|
||||
onCallEnded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package calls.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import calls.data.remote.GroupWebRtcManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ParticipantState(
|
||||
val userId: String,
|
||||
val videoTrack: VideoTrack? = null,
|
||||
val isAudioMuted: Boolean = false,
|
||||
val isVideoDisabled: Boolean = false
|
||||
)
|
||||
|
||||
data class GroupCallState(
|
||||
val chatId: String? = null,
|
||||
val participants: Map<String, ParticipantState> = emptyMap(),
|
||||
val isMicEnabled: Boolean = true,
|
||||
val isCameraEnabled: Boolean = true
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class GroupCallViewModel @Inject constructor(
|
||||
private val webRtcManager: GroupWebRtcManager,
|
||||
private val signalrClient: ChatHubClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(GroupCallState())
|
||||
val state: StateFlow<GroupCallState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
observeSignalREvents()
|
||||
}
|
||||
|
||||
private fun observeSignalREvents() {
|
||||
signalrClient.events.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
|
||||
is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
|
||||
is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
|
||||
is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
|
||||
// Дополнительные обработчики ICE кандидатов и т.д.
|
||||
else -> Unit
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun handleUserJoined(userId: String) {
|
||||
// Создаем PeerConnection для нового участника
|
||||
// Логика идентична портированному CallModal.tsx
|
||||
_state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
|
||||
}
|
||||
|
||||
private fun handleUserLeft(userId: String) {
|
||||
webRtcManager.removeParticipant(userId)
|
||||
_state.update { it.copy(participants = it.participants - userId) }
|
||||
}
|
||||
|
||||
private fun handleOffer(from: String, sdp: String) {
|
||||
// Установка RemoteDescription и создание Answer
|
||||
}
|
||||
|
||||
private fun handleAnswer(from: String, sdp: String) {
|
||||
// Установка RemoteDescription
|
||||
}
|
||||
|
||||
fun toggleMic() {
|
||||
_state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
|
||||
// Логика управления AudioTrack
|
||||
}
|
||||
|
||||
fun toggleCamera() {
|
||||
_state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
|
||||
// Логика управления VideoTrack
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
webRtcManager.closeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package calls.presentation.components
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.SurfaceViewRenderer
|
||||
import org.webrtc.VideoTrack
|
||||
|
||||
@Composable
|
||||
fun VideoGrid(
|
||||
participants: Map<String, VideoTrack?>,
|
||||
localVideoTrack: VideoTrack?
|
||||
) {
|
||||
val eglBaseContext = remember { EglBase.create().eglBaseContext }
|
||||
val allVideoTracks = remember(participants, localVideoTrack) {
|
||||
listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
) {
|
||||
items(allVideoTracks) { track ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
|
||||
) {
|
||||
VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoRenderer(
|
||||
videoTrack: VideoTrack,
|
||||
eglBaseContext: EglBase.Context
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SurfaceViewRenderer(context).apply {
|
||||
init(eglBaseContext, null)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
videoTrack.addSink(view)
|
||||
},
|
||||
onRelease = { view ->
|
||||
videoTrack.removeSink(view)
|
||||
view.release()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package chats.data.remote.signalr
|
||||
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
|
||||
sealed class ChatEvent {
|
||||
data class NewMessage(val message: MessageDto) : ChatEvent()
|
||||
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
||||
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
||||
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
||||
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class UserOnline(val userId: String) : ChatEvent()
|
||||
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
|
||||
data class NewChat(val chat: ChatDto) : ChatEvent()
|
||||
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String) : ChatEvent()
|
||||
|
||||
// Call Events (WebRTC Signaling)
|
||||
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
|
||||
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
|
||||
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
|
||||
data class CallEnded(val chatId: String) : ChatEvent()
|
||||
|
||||
// Group Call Events
|
||||
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
|
||||
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
|
||||
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
|
||||
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package chats.domain.model
|
||||
|
||||
data class Chat(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val name: String,
|
||||
val avatar: String?,
|
||||
val unreadCount: Int,
|
||||
val lastMessage: Message?
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package chats.domain.usecase
|
||||
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.api.FileUploadResponse
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class UploadMediaUseCase @Inject constructor(
|
||||
private val api: ChatApi
|
||||
) {
|
||||
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
|
||||
return try {
|
||||
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
|
||||
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||
val response = api.uploadFile(body)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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...
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun EmojiPicker(
|
||||
onEmojiSelected: (String) -> Unit
|
||||
) {
|
||||
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "✨") // Базовый набор
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(6),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
items(emojis) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clickable { onEmojiSelected(emoji) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = emoji, fontSize = 24.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import chats.data.remote.api.KlipyGifDto
|
||||
|
||||
@Composable
|
||||
fun KlipyPicker(
|
||||
gifs: List<KlipyGifDto>,
|
||||
isLoading: Boolean,
|
||||
onGifSelected: (String) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val context = LocalContext.current
|
||||
val imageLoader = coil.ImageLoader.Builder(context)
|
||||
.components {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
onSearch(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
placeholder = { Text("Search GIFs...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
) {
|
||||
items(gifs) { gif ->
|
||||
val url = gif.images?.fixed_height?.url ?: ""
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
imageLoader = imageLoader,
|
||||
contentDescription = gif.title,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.5f)
|
||||
.clickable { onGifSelected(url) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import core.utils.LinkMetadata
|
||||
|
||||
@Composable
|
||||
fun LinkPreview(
|
||||
metadata: LinkMetadata,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(contentColor.copy(alpha = 0.05f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (metadata.imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = metadata.imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
metadata.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
metadata.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = metadata.url.removePrefix("https://").removePrefix("http://").split("/")[0],
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package contacts.data.remote.api
|
||||
|
||||
import retrofit2.http.*
|
||||
|
||||
interface ContactApi {
|
||||
@GET("contacts")
|
||||
suspend fun getContacts(): List<ContactDto>
|
||||
|
||||
@POST("contacts/add")
|
||||
suspend fun addContact(@Body userId: String): ContactDto
|
||||
|
||||
@DELETE("contacts/{userId}")
|
||||
suspend fun removeContact(@Path("userId") userId: String)
|
||||
|
||||
@GET("users/search")
|
||||
suspend fun searchUsers(@Query("query") query: String): List<ContactDto>
|
||||
}
|
||||
|
||||
data class ContactDto(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val displayName: String?,
|
||||
val avatarUrl: String?,
|
||||
val isOnline: Boolean,
|
||||
val lastSeen: String?
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
package contacts.presentation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import contacts.data.remote.api.ContactDto
|
||||
import core.presentation.components.AppAvatar
|
||||
import core.presentation.theme.SoftSquareShape
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactListScreen(
|
||||
contacts: List<ContactDto>,
|
||||
isLoading: Boolean = false,
|
||||
onContactClick: (String) -> Unit,
|
||||
onSearchChange: (String) -> Unit
|
||||
) {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf(
|
||||
stringResource(R.string.all),
|
||||
stringResource(R.string.online_tab)
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.contacts_title)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Поисковая строка
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = {
|
||||
searchQuery = it
|
||||
onSearchChange(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
shape = SoftSquareShape
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
val filteredContacts = contacts.filter {
|
||||
if (selectedTab == 1) it.isOnline else true
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(filteredContacts) { contact ->
|
||||
ContactItem(contact = contact, onClick = { onContactClick(contact.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(16.dp, 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppAvatar(
|
||||
url = contact.avatarUrl,
|
||||
name = contact.username,
|
||||
size = 56.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = contact.displayName ?: contact.username,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val statusText = if (contact.isOnline) {
|
||||
stringResource(R.string.online)
|
||||
} else {
|
||||
stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
|
||||
}
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package contacts.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import contacts.data.remote.api.ContactDto
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ContactListState(
|
||||
val contacts: List<ContactDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ContactListViewModel @Inject constructor() : ViewModel() {
|
||||
private val _state = MutableStateFlow(ContactListState())
|
||||
val state: StateFlow<ContactListState> = _state.asStateFlow()
|
||||
|
||||
fun onSearchChange(query: String) {
|
||||
// Заглушка поиска
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package core.di
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideContext(@ApplicationContext context: Context): Context {
|
||||
return context
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGson(): Gson {
|
||||
return Gson()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package core.di
|
||||
|
||||
import core.network.AuthInterceptor
|
||||
import core.network.DynamicBaseUrlInterceptor
|
||||
import core.network.ServerConfig
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
authInterceptor: AuthInterceptor,
|
||||
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.addInterceptor(dynamicBaseUrlInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("https://api.placeholder.com/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package core.network
|
||||
|
||||
import core.security.TokenManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val tokenManager: TokenManager
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = tokenManager.getToken()
|
||||
val request = chain.request().newBuilder().apply {
|
||||
token?.let {
|
||||
addHeader("Authorization", "Bearer $it")
|
||||
}
|
||||
}.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package core.network
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DynamicBaseUrlInterceptor @Inject constructor(
|
||||
private val serverConfig: ServerConfig
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val newBaseUrlString = serverConfig.getBaseUrl()
|
||||
val newBaseUrl = newBaseUrlString.toHttpUrlOrNull() ?: return chain.proceed(originalRequest)
|
||||
|
||||
val oldUrl = originalRequest.url
|
||||
|
||||
// Формируем новый URL.
|
||||
// Retrofit-пути у нас начинаются БЕЗ слеша (например "auth/login")
|
||||
// Поэтому мы просто берем путь из настроек и добавляем к нему путь запроса.
|
||||
val newUrl = newBaseUrl.newBuilder().apply {
|
||||
val pathSegments = oldUrl.pathSegments
|
||||
// Добавляем сегменты пути запроса к базовому пути из настроек
|
||||
pathSegments.forEach { segment ->
|
||||
if (segment.isNotEmpty()) {
|
||||
addEncodedPathSegment(segment)
|
||||
}
|
||||
}
|
||||
// Копируем параметры запроса (query params)
|
||||
for (i in 0 until oldUrl.querySize) {
|
||||
addEncodedQueryParameter(
|
||||
oldUrl.queryParameterName(i),
|
||||
oldUrl.queryParameterValue(i)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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