Удалил к херам нативную версию

This commit is contained in:
Халимов Рустам
2026-05-13 13:49:46 +03:00
parent df4feeeee5
commit 89e325556c
132 changed files with 0 additions and 13726 deletions

View File

View File

@@ -1,146 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
kotlin("kapt")
}
android {
namespace = "ru.knot.messager"
compileSdk = 34
defaultConfig {
applicationId = "ru.knot.messager"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
// Подключаем все наши папки с кодом как sourceSets
sourceSets {
getByName("main") {
java.srcDirs(
"src/main/kotlin",
"../auth",
"../chats",
"../core",
"../calls",
"../stories",
"../contacts",
"../profiles",
"../settings",
"../navigation"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
// Включаем оптимизации компилятора Compose
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// AndroidX & UI
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.navigation:navigation-compose:2.7.5")
implementation("androidx.compose.material:material-icons-extended")
// Hilt
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
// Network & SignalR
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.microsoft.signalr:signalr:7.0.0")
// WebRTC
implementation("com.github.webrtc-sdk:android:104.5112.01")
// Media3 (ExoPlayer)
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media3:media3-ui:1.2.0")
implementation("androidx.media3:media3-common:1.2.0")
// Images & GIF
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0")
implementation("io.coil-kt:coil-svg:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
// Security
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// UCrop (Image Cropping)
implementation("com.github.yalantis:ucrop:2.2.8")
// Firebase (Push Notifications)
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// Room
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// WorkManager
val work_version = "2.9.0"
implementation("androidx.work:work-runtime-ktx:$work_version")
// Paging 3
val paging_version = "3.3.0"
implementation("androidx.paging:paging-runtime-ktx:$paging_version")
implementation("androidx.paging:paging-compose:$paging_version")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

View File

@@ -1,29 +0,0 @@
{
"project_info": {
"project_number": "483917912506",
"project_id": "knot-bad1a",
"storage_bucket": "knot-bad1a.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
"android_client_info": {
"package_name": "ru.knot.messager"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ru.knot.messager">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name="com.knot.messenger.MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.KnotMessenger"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name="com.knot.messenger.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KnotMessenger">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="core.notifications.data.KnotFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -1,58 +0,0 @@
package com.knot.messenger
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import navigation.AppNavigation
import core.presentation.theme.KnotTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@javax.inject.Inject
lateinit var navigationManager: core.utils.NavigationManager
@javax.inject.Inject
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
signalrNotificationObserver.start()
intent.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
// Request notifications permission for Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.app.ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
101
)
}
setContent {
KnotTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation(navigationManager = navigationManager)
}
}
}
}
override fun onNewIntent(intent: android.content.Intent?) {
super.onNewIntent(intent)
intent?.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
}
}

View File

@@ -1,30 +0,0 @@
package com.knot.messenger
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(VideoFrameDecoder.Factory())
}
.build()
}
}

View File

@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Цветная иконка для notification -->
<path
android:fillColor="#6C5CE7"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,63 +0,0 @@
<resources>
<string name="app_name">Knot</string>
<string name="login">Login</string>
<string name="register">Register</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="display_name">Display Name</string>
<string name="settings">Settings</string>
<string name="save">Save</string>
<string name="back">Back</string>
<string name="server_connection">Server Connection</string>
<string name="api_base_url">API Base URL</string>
<string name="server_features">Server Features</string>
<string name="stories">Stories</string>
<string name="polls">Polls</string>
<string name="calls">Calls</string>
<string name="groups">Groups</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="limits">Limits</string>
<string name="max_file_size">Max File Size</string>
<string name="max_group_members">Max Group Members</string>
<string name="message">Message</string>
<string name="call">Call</string>
<string name="block">Block</string>
<string name="profile">Profile</string>
<string name="confirm_password">Confirm Password</string>
<string name="passwords_not_match">Passwords do not match</string>
<string name="no_account_register">Don\'t have an account? Register</string>
<string name="already_have_account">Already have an account? Login</string>
<string name="error_occurred">An error occurred</string>
<string name="loading">Loading...</string>
<string name="chats_title">Chats</string>
<string name="contacts_title">Contacts</string>
<string name="stories_title">Stories</string>
<string name="create_story">Create Story</string>
<string name="send_message_hint">Type a message...</string>
<string name="reply_to_user">Reply to %1$s...</string>
<string name="story_editor">STORY EDITOR</string>
<string name="publish">PUBLISH</string>
<string name="start_creation">START CREATION</string>
<string name="text_tool">TEXT</string>
<string name="crop_tool">CROP</string>
<string name="stickers_tool">STICKERS</string>
<string name="brush_tool">BRUSH</string>
<string name="filters_tool">FILTERS</string>
<string name="remove">Remove</string>
<string name="no_chats_found">No chats found</string>
<string name="typing">typing...</string>
<string name="video_call">Video Call</string>
<string name="emoji">Emoji</string>
<string name="attach">Attach</string>
<string name="message_placeholder">Message...</string>
<string name="voice_message">Voice Message</string>
<string name="send">Send</string>
<string name="reply_photo">Photo</string>
<string name="reply_video">Video</string>
<string name="reply_audio">Audio</string>
<string name="reply_file">File</string>
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Reply to </string>
<string name="reply_self">yourself</string>
</resources>

View File

@@ -1,85 +0,0 @@
<resources>
<string name="app_name">Knot</string>
<string name="login">Войти</string>
<string name="register">Регистрация</string>
<string name="username">Имя пользователя</string>
<string name="password">Пароль</string>
<string name="display_name">Отображаемое имя</string>
<string name="settings">Настройки</string>
<string name="save">Сохранить</string>
<string name="back">Назад</string>
<string name="server_connection">Подключение к серверу</string>
<string name="api_base_url">API Base URL</string>
<string name="server_features">Функции сервера</string>
<string name="stories">Истории</string>
<string name="polls">Опросы</string>
<string name="calls">Звонки</string>
<string name="groups">Группы</string>
<string name="enabled">Включено</string>
<string name="disabled">Отключено</string>
<string name="limits">Лимиты</string>
<string name="max_file_size">Макс. размер файла</string>
<string name="max_group_members">Макс. участников в группе</string>
<string name="message">Сообщение</string>
<string name="call">Позвонить</string>
<string name="block">Заблокировать</string>
<string name="profile">Профиль</string>
<string name="confirm_password">Подтвердите пароль</string>
<string name="passwords_not_match">Пароли не совпадают</string>
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
<string name="already_have_account">Уже есть аккаунт? Войти</string>
<string name="error_occurred">Произошла ошибка</string>
<string name="loading">Загрузка...</string>
<string name="chats_title">Чаты</string>
<string name="contacts_title">Контакты</string>
<string name="stories_title">Истории</string>
<string name="create_story">Создать историю</string>
<string name="send_message_hint">Напишите сообщение...</string>
<string name="reply_to_user">Ответить %1$s...</string>
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
<string name="publish">ОПУБЛИКОВАТЬ</string>
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
<string name="text_tool">ТЕКСТ</string>
<string name="crop_tool">ОБРЕЗКА</string>
<string name="stickers_tool">СТИКЕРЫ</string>
<string name="brush_tool">КИСТЬ</string>
<string name="filters_tool">ФИЛЬТРЫ</string>
<string name="remove">Удалить</string>
<string name="no_chats_found">Чаты не найдены</string>
<string name="typing">печатает...</string>
<string name="video_call">Видеозвонок</string>
<string name="emoji">Эмодзи</string>
<string name="attach">Прикрепить</string>
<string name="message_placeholder">Сообщение...</string>
<string name="voice_message">Голосовое сообщение</string>
<string name="send">Отправить</string>
<string name="search_hint">Поиск...</string>
<string name="online">В сети</string>
<string name="last_seen">Был(а): %1$s</string>
<string name="last_seen_recently">недавно</string>
<string name="all">Все</string>
<string name="online_tab">Онлайн</string>
<string name="blocked">Заблокированные</string>
<string name="media">Медиа</string>
<string name="notifications">Уведомления</string>
<string name="mute">Без звука</string>
<string name="unmute">Включить звук</string>
<string name="log_out">Выйти из аккаунта</string>
<string name="bio">О себе</string>
<string name="edit_profile">Редактировать профиль</string>
<string name="username_label">Имя пользователя</string>
<string name="change_photo">Изменить фото</string>
<string name="cancel">Отмена</string>
<string name="crop">Обрезать</string>
<string name="chats">Чаты</string>
<string name="contacts_tab">Контакты</string>
<string name="profile_tab">Профиль</string>
<string name="saving">Сохранение...</string>
<string name="reply_photo">Фото</string>
<string name="reply_video">Видео</string>
<string name="reply_audio">Аудио</string>
<string name="reply_file">Файл</string>
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Ответ </string>
<string name="reply_self">самому себе</string>
</resources>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">#0F0F10</item>
<item name="android:windowBackground">#0F0F10</item>
</style>
</resources>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Для отладки: доверяем пользовательским сертификатам -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
<certificates src="system" />
</trust-anchors>
</debug-overrides>
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.0.0/8</domain>
<domain includeSubdomains="true">172.16.0.0/12</domain>
<domain includeSubdomains="true">192.168.0.0/16</domain>
</domain-config>
</network-security-config>

View File

@@ -1,29 +0,0 @@
package auth.data.remote.api
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.AuthResponse
import auth.data.remote.dto.RefreshTokenRequest
import core.domain.model.ServerConfigModel
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
interface AuthApi {
@POST("auth/login")
suspend fun login(@Body request: AuthRequest): AuthResponse
@POST("auth/register")
suspend fun register(@Body request: AuthRequest): AuthResponse
@POST("auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse
@GET("config")
@Headers("Cache-Control: no-cache")
suspend fun getConfig(): ServerConfigModel
@POST("auth/push-token")
@Headers("Cache-Control: no-cache")
suspend fun updatePushToken(@Body token: String): Unit
}

View File

@@ -1,28 +0,0 @@
package auth.data.remote.dto
import com.google.gson.annotations.SerializedName
data class AuthRequest(
@SerializedName("userName") val userName: String,
@SerializedName("password") val password: String
)
data class AuthResponse(
@SerializedName("accessToken") val accessToken: String?,
@SerializedName("refreshToken") val refreshToken: String?,
@SerializedName("user") val user: UserDto?,
@SerializedName("userId") val userId: String?,
@SerializedName("username") val username: String?,
@SerializedName("displayName") val displayName: String?
)
data class UserDto(
@SerializedName("id") val id: String,
@SerializedName("userName") val userName: String,
@SerializedName("displayName") val displayName: String?,
@SerializedName("avatarUrl") val avatarUrl: String?
)
data class RefreshTokenRequest(
@SerializedName("refreshToken") val refreshToken: String
)

View File

@@ -1,131 +0,0 @@
package auth.data.repository
import auth.data.remote.api.AuthApi
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.RefreshTokenRequest
import auth.domain.model.AuthResult
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
private val api: AuthApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig
) : AuthRepository {
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
override suspend fun login(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.login(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId, response.refreshToken)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
refreshToken = response.refreshToken,
userId = userId,
userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun register(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.register(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId, response.refreshToken)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
refreshToken = response.refreshToken,
userId = userId,
userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
tokenManager.deleteToken()
_isAuthenticated.value = false
}
override fun isAuthenticated(): Boolean {
return _isAuthenticated.value
}
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
return _isAuthenticated.asStateFlow()
}
override suspend fun fetchConfig(): Result<Unit> {
return try {
val config = api.getConfig()
serverConfig.saveServerConfig(config)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updatePushToken(token: String) {
try {
api.updatePushToken(token)
} catch (e: Exception) {
// Silent fail
}
}
override suspend fun refreshToken(): Result<AuthResult> {
val currentRefreshToken = tokenManager.getRefreshToken()
if (currentRefreshToken == null) {
return Result.failure(Exception("Refresh token is null"))
}
return try {
val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken))
val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null"))
val newRefreshToken = response.refreshToken
val userId = response.userId ?: ""
tokenManager.saveToken(newAccessToken, userId, newRefreshToken)
_isAuthenticated.value = true
Result.success(
AuthResult(
token = newAccessToken,
refreshToken = newRefreshToken,
userId = userId,
userName = response.username ?: "",
displayName = response.displayName ?: "",
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -1,34 +0,0 @@
package auth.di
import auth.data.remote.api.AuthApi
import auth.data.repository.AuthRepositoryImpl
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi {
return retrofit.create(AuthApi::class.java)
}
@Provides
@Singleton
fun provideAuthRepository(
api: AuthApi,
tokenManager: TokenManager,
serverConfig: ServerConfig
): AuthRepository {
return AuthRepositoryImpl(api, tokenManager, serverConfig)
}
}

View File

@@ -1,10 +0,0 @@
package auth.domain.model
data class AuthResult(
val token: String,
val refreshToken: String?,
val userId: String,
val userName: String,
val displayName: String,
val avatarUrl: String?
)

View File

@@ -1,14 +0,0 @@
package auth.domain.repository
import auth.domain.model.AuthResult
interface AuthRepository {
suspend fun login(userName: String, password: String): Result<AuthResult>
suspend fun register(userName: String, password: String): Result<AuthResult>
suspend fun logout()
suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
suspend fun updatePushToken(token: String)
suspend fun refreshToken(): Result<AuthResult>
}

View File

@@ -1,94 +0,0 @@
package auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import auth.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import com.google.firebase.messaging.FirebaseMessaging
import javax.inject.Inject
data class AuthState(
val isLoading: Boolean = false,
val error: String? = null,
val isAuthenticated: Boolean = false
)
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.isAuthenticatedFlow().collect { authenticated ->
_state.update { it.copy(isAuthenticated = authenticated) }
if (authenticated) {
updatePushToken()
}
}
}
}
fun checkAuth() {
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
}
fun login(userName: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.login(userName, password)
.onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun register(userName: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.register(userName, password)
.onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun refreshToken() {
viewModelScope.launch {
repository.refreshToken()
.onSuccess {
// Token refreshed successfully
}
.onFailure {
// Refresh failed, will trigger logout via AuthInterceptor
}
}
}
private fun updatePushToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
repository.updatePushToken(token)
}
}
}
}
}

View File

@@ -1,95 +0,0 @@
package auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onNavigateToRegister: () -> Unit,
onNavigateToSettings: () -> Unit,
onLoginSuccess: () -> Unit
) {
val state by viewModel.state.collectAsState()
var userName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
LaunchedEffect(state.isAuthenticated) {
if (state.isAuthenticated) {
onLoginSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.login)) },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = { viewModel.login(userName, password) },
modifier = Modifier.fillMaxWidth(),
enabled = userName.isNotBlank() && password.isNotBlank()
) {
Text(stringResource(R.string.login))
}
TextButton(onClick = onNavigateToRegister) {
Text(stringResource(R.string.no_account_register))
}
}
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}

View File

@@ -1,106 +0,0 @@
package auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
viewModel: AuthViewModel,
onNavigateToLogin: () -> Unit,
onRegisterSuccess: () -> Unit
) {
val state by viewModel.state.collectAsState()
var userName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
LaunchedEffect(state.isAuthenticated) {
if (state.isAuthenticated) {
onRegisterSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.register)) })
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(R.string.confirm_password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
if (password == confirmPassword) {
errorMessage = null
viewModel.register(userName, password)
} else {
errorMessage = passwordsNotMatchMsg
}
},
modifier = Modifier.fillMaxWidth(),
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
) {
Text(stringResource(R.string.register))
}
TextButton(onClick = onNavigateToLogin) {
Text(stringResource(R.string.already_have_account))
}
}
val displayError = state.error ?: errorMessage
if (displayError != null) {
Text(
text = displayError,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}

View File

@@ -1,8 +0,0 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("com.android.library") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
id("com.google.gms.google-services") version "4.4.0" apply false
}

View File

@@ -1,43 +0,0 @@
package calls.data.remote
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
class CallAudioManager(private val context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var originalMode: Int = AudioManager.MODE_NORMAL
private var originalIsSpeakerphoneOn: Boolean = false
fun startCallMode(isVideoCall: Boolean) {
originalMode = audioManager.mode
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setSpeakerphoneOn(isVideoCall)
// Запрашиваем фокус аудио
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val playbackAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(playbackAttributes)
.build()
audioManager.requestAudioFocus(focusRequest)
}
}
fun setSpeakerphoneOn(on: Boolean) {
audioManager.isSpeakerphoneOn = on
}
fun stopCallMode() {
audioManager.mode = originalMode
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
audioManager.abandonAudioFocus(null)
}
}

View File

@@ -1,46 +0,0 @@
package calls.data.remote
import android.content.Context
import org.webrtc.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GroupWebRtcManager @Inject constructor(private val context: Context) {
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
private val factory: PeerConnectionFactory by lazy { createFactory() }
private fun createFactory(): PeerConnectionFactory {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
return PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
}
fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
val pc = factory.createPeerConnection(iceServers, observer)
if (pc != null) {
peerConnections[userId] = pc
}
return pc
}
fun removeParticipant(userId: String) {
peerConnections[userId]?.dispose()
peerConnections.remove(userId)
}
fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
fun closeAll() {
peerConnections.values.forEach { it.dispose() }
peerConnections.clear()
}
}

View File

@@ -1,50 +0,0 @@
package calls.data.remote
import android.content.Context
import org.webrtc.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebRtcManager @Inject constructor(private val context: Context) {
private var peerConnection: PeerConnection? = null
private val factory: PeerConnectionFactory by lazy { createFactory() }
// Аудио и видео источники
private val videoSource by lazy { factory.createVideoSource(false) }
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
private fun createFactory(): PeerConnectionFactory {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
return PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
}
fun initializePeerConnection(observer: PeerConnection.Observer) {
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
peerConnection = factory.createPeerConnection(iceServers, observer)
}
fun createOffer(observer: SdpObserver) {
peerConnection?.createOffer(observer, MediaConstraints())
}
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
}
fun addIceCandidate(candidate: IceCandidate) {
peerConnection?.addIceCandidate(candidate)
}
fun close() {
peerConnection?.dispose()
peerConnection = null
}
}

View File

@@ -1,148 +0,0 @@
package calls.presentation
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.presentation.components.AppAvatar
import ru.knot.messager.R
@Composable
fun CallScreen(
viewModel: CallViewModel,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF0F0F10),
Color(0xFF161618),
Color(0xFF6366F1).copy(alpha = 0.2f)
)
)
)
) {
// Контент звонка (Аватар или Видео)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 100.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppAvatar(
url = state.callerAvatar,
name = state.callerName,
size = 140.dp
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = state.callerName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold
)
Text(
text = when (state.status) {
CallStatus.INCOMING -> "Входящий звонок..."
CallStatus.OUTGOING -> "Вызов..."
CallStatus.CONNECTED -> "00:00" // TODO: Timer
CallStatus.ENDED -> "Звонок завершен"
else -> ""
},
style = MaterialTheme.typography.bodyLarge,
color = Color.White.copy(alpha = 0.7f)
)
}
// Кнопки управления (Внизу)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 60.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (state.status == CallStatus.INCOMING) {
// Кнопка отклонить
CallActionButton(
icon = Icons.Default.CallEnd,
backgroundColor = Color.Red,
onClick = { viewModel.endCall(); onBack() }
)
// Кнопка принять
CallActionButton(
icon = Icons.Default.Call,
backgroundColor = Color(0xFF10B981), // Green
onClick = { viewModel.acceptCall() }
)
} else {
// Стандартные кнопки во время разговора
IconButton(
onClick = { /* viewModel.toggleMic() */ },
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
) {
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
}
CallActionButton(
icon = Icons.Default.CallEnd,
backgroundColor = Color.Red,
onClick = { viewModel.endCall(); onBack() }
)
IconButton(
onClick = { /* viewModel.toggleSpeaker() */ },
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
) {
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
}
}
}
}
}
}
@Composable
fun CallActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
backgroundColor: Color,
onClick: () -> Unit
) {
FloatingActionButton(
onClick = onClick,
containerColor = backgroundColor,
contentColor = Color.White,
shape = CircleShape,
modifier = Modifier.size(64.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
}
}

View File

@@ -1,99 +0,0 @@
package calls.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import calls.data.remote.WebRtcManager
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.*
import javax.inject.Inject
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
data class CallState(
val status: CallStatus = CallStatus.IDLE,
val chatId: String? = null,
val callerName: String = "",
val callerAvatar: String? = null,
val isMuted: Boolean = false,
val isSpeakerOn: Boolean = false,
val localVideoTrack: VideoTrack? = null,
val remoteVideoTrack: VideoTrack? = null
)
@HiltViewModel
class CallViewModel @Inject constructor(
private val webRtcManager: WebRtcManager,
private val signalrClient: ChatHubClient
) : ViewModel() {
private val _state = MutableStateFlow(CallState())
val state: StateFlow<CallState> = _state.asStateFlow()
init {
observeSignaling()
}
private fun observeSignaling() {
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.CallIncoming -> onIncomingCall(event)
is ChatEvent.CallAnswered -> onCallAnswered(event)
is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
is ChatEvent.CallEnded -> onCallEnded()
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun onIncomingCall(event: ChatEvent.CallIncoming) {
_state.update { it.copy(
status = CallStatus.INCOMING,
chatId = event.chatId,
callerName = "User ${event.from}" // TODO: Load actual user info
) }
// Set remote description from offer
webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
})
}
private fun onCallAnswered(event: ChatEvent.CallAnswered) {
_state.update { it.copy(status = CallStatus.CONNECTED) }
webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
})
}
private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
// Parse candidate JSON and add to peer connection
// webRtcManager.addIceCandidate(...)
}
private fun onCallEnded() {
_state.update { it.copy(status = CallStatus.ENDED) }
webRtcManager.close()
}
fun acceptCall() {
val chatId = _state.value.chatId ?: return
// Create answer and send via SignalR
}
fun endCall() {
val chatId = _state.value.chatId ?: return
// Send call_end via SignalR
onCallEnded()
}
}

View File

@@ -1,87 +0,0 @@
package calls.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent
import calls.data.remote.GroupWebRtcManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.*
import javax.inject.Inject
data class ParticipantState(
val userId: String,
val videoTrack: VideoTrack? = null,
val isAudioMuted: Boolean = false,
val isVideoDisabled: Boolean = false
)
data class GroupCallState(
val chatId: String? = null,
val participants: Map<String, ParticipantState> = emptyMap(),
val isMicEnabled: Boolean = true,
val isCameraEnabled: Boolean = true
)
@HiltViewModel
class GroupCallViewModel @Inject constructor(
private val webRtcManager: GroupWebRtcManager,
private val signalrClient: ChatHubClient
) : ViewModel() {
private val _state = MutableStateFlow(GroupCallState())
val state: StateFlow<GroupCallState> = _state.asStateFlow()
init {
observeSignalREvents()
}
private fun observeSignalREvents() {
signalrClient.events.onEach { event ->
when (event) {
is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
// Дополнительные обработчики ICE кандидатов и т.д.
else -> Unit
}
}.launchIn(viewModelScope)
}
private fun handleUserJoined(userId: String) {
// Создаем PeerConnection для нового участника
// Логика идентична портированному CallModal.tsx
_state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
}
private fun handleUserLeft(userId: String) {
webRtcManager.removeParticipant(userId)
_state.update { it.copy(participants = it.participants - userId) }
}
private fun handleOffer(from: String, sdp: String) {
// Установка RemoteDescription и создание Answer
}
private fun handleAnswer(from: String, sdp: String) {
// Установка RemoteDescription
}
fun toggleMic() {
_state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
// Логика управления AudioTrack
}
fun toggleCamera() {
_state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
// Логика управления VideoTrack
}
override fun onCleared() {
super.onCleared()
webRtcManager.closeAll()
}
}

View File

@@ -1,67 +0,0 @@
package calls.presentation.components
import android.view.ViewGroup
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import org.webrtc.EglBase
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
fun VideoGrid(
participants: Map<String, VideoTrack?>,
localVideoTrack: VideoTrack?
) {
val eglBaseContext = remember { EglBase.create().eglBaseContext }
val allVideoTracks = remember(participants, localVideoTrack) {
listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
}
LazyVerticalGrid(
columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
items(allVideoTracks) { track ->
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
) {
VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
}
}
}
}
@Composable
fun VideoRenderer(
videoTrack: VideoTrack,
eglBaseContext: EglBase.Context
) {
AndroidView(
factory = { context ->
SurfaceViewRenderer(context).apply {
init(eglBaseContext, null)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
},
update = { view ->
videoTrack.addSink(view)
},
onRelease = { view ->
videoTrack.removeSink(view)
view.release()
}
)
}

View File

@@ -1,45 +0,0 @@
package chats.data.local.dao
import androidx.room.*
import chats.data.local.database.ChatEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO для операций с чатами в Room Database
*/
@Dao
interface ChatDao {
@Query("SELECT * FROM chats ORDER BY lastMessageTimestamp DESC")
fun getAllChats(): Flow<List<ChatEntity>>
@Query("SELECT * FROM chats WHERE remoteId = :remoteId LIMIT 1")
suspend fun getChatByRemoteId(remoteId: String): ChatEntity?
@Query("SELECT * FROM chats WHERE localId = :localId LIMIT 1")
suspend fun getChatByLocalId(localId: String): ChatEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChat(chat: ChatEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChats(chats: List<ChatEntity>)
@Update
suspend fun updateChat(chat: ChatEntity)
@Query("UPDATE chats SET lastMessageText = :lastMessageText, lastMessageTimestamp = :timestamp WHERE remoteId = :chatId")
suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: Long?)
@Query("UPDATE chats SET unreadCount = :count WHERE remoteId = :chatId")
suspend fun updateUnreadCount(chatId: String, count: Int)
@Delete
suspend fun deleteChat(chat: ChatEntity)
@Query("DELETE FROM chats WHERE remoteId = :remoteId")
suspend fun deleteChatByRemoteId(remoteId: String)
@Query("DELETE FROM chats")
suspend fun deleteAllChats()
}

View File

@@ -1,83 +0,0 @@
package chats.data.local.dao
import androidx.room.*
import chats.data.local.database.MessageEntity
import chats.domain.model.MessageStatus
import kotlinx.coroutines.flow.Flow
/**
* DAO для операций с сообщениями в Room Database
*/
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
fun getMessagesByChatId(chatId: String): Flow<List<MessageEntity>>
/**
* Пагинированная загрузка сообщений для Paging 3
* sequenceId увеличивается от старых к новым, поэтому ORDER BY DESC для отображения newest внизу
*/
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT :limit OFFSET :offset")
suspend fun getMessagesPaged(chatId: String, offset: Int, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId DESC LIMIT 1")
suspend fun getLastMessage(chatId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC LIMIT 1")
suspend fun getFirstMessage(chatId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId > :afterSequenceId ORDER BY sequenceId ASC LIMIT :limit")
suspend fun getMessagesAfter(chatId: String, afterSequenceId: Long, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId < :beforeSequenceId ORDER BY sequenceId DESC LIMIT :limit")
suspend fun getMessagesBefore(chatId: String, beforeSequenceId: Long, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE localId = :localId")
suspend fun getMessageByLocalId(localId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE serverId = :serverId")
suspend fun getMessageByServerId(serverId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE status IN (:statuses) AND chatId = :chatId")
suspend fun getMessagesByStatus(chatId: String, statuses: List<MessageStatus>): List<MessageEntity>
@Query("SELECT * FROM messages WHERE status = :status")
fun getMessagesByStatusFlow(status: MessageStatus): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE status IN (:statuses)")
suspend fun getPendingMessages(statuses: List<MessageStatus>): List<MessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@Update
suspend fun updateMessage(message: MessageEntity)
@Delete
suspend fun deleteMessage(message: MessageEntity)
@Query("DELETE FROM messages WHERE localId = :localId")
suspend fun deleteMessageByLocalId(localId: String)
@Query("DELETE FROM messages WHERE chatId = :chatId")
suspend fun deleteMessagesByChatId(chatId: String)
@Query("UPDATE messages SET status = :status, updatedAtMillis = :updatedAtMillis WHERE localId = :localId")
suspend fun updateMessageStatus(localId: String, status: MessageStatus, updatedAtMillis: Long)
@Query("UPDATE messages SET serverId = :serverId, status = :status, updatedAtMillis = :updatedAtMillis WHERE localId = :localId")
suspend fun updateMessageWithServerId(localId: String, serverId: String, status: MessageStatus, updatedAtMillis: Long)
@Query("UPDATE messages SET status = :status, errorMessage = :errorMessage, retryCount = retryCount + 1, updatedAtMillis = :updatedAtMillis WHERE localId = :localId")
suspend fun updateMessageError(localId: String, status: MessageStatus, errorMessage: String, updatedAtMillis: Long)
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND status IN (:statuses)")
suspend fun getUnsentCount(chatId: String, statuses: List<MessageStatus>): Int
@Query("SELECT * FROM messages WHERE chatId = :chatId AND status = :status LIMIT 1")
suspend fun getFirstMessageByStatus(chatId: String, status: MessageStatus): MessageEntity?
}

View File

@@ -1,36 +0,0 @@
package chats.data.local.dao
import androidx.room.*
import chats.data.local.database.UserProfileEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO для операций с профилями пользователей в Room Database
*/
@Dao
interface UserProfileDao {
@Query("SELECT * FROM user_profile WHERE userId = :userId LIMIT 1")
suspend fun getUserById(userId: String): UserProfileEntity?
@Query("SELECT * FROM user_profile WHERE userId = :userId")
fun getUserByIdFlow(userId: String): Flow<UserProfileEntity?>
@Query("SELECT * FROM user_profile")
fun getAllUsers(): Flow<List<UserProfileEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserProfileEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUsers(users: List<UserProfileEntity>)
@Update
suspend fun updateUser(user: UserProfileEntity)
@Delete
suspend fun deleteUser(user: UserProfileEntity)
@Query("DELETE FROM user_profile WHERE userId = :userId")
suspend fun deleteUserById(userId: String)
}

View File

@@ -1,33 +0,0 @@
package chats.data.local.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.dao.UserProfileDao
/**
* Room Database для локального хранения данных мессенджера
* Реализует паттерн Single Source of Truth
*/
@Database(
entities = [
MessageEntity::class,
ChatEntity::class,
UserProfileEntity::class
],
version = 2,
exportSchema = false
)
@TypeConverters(MessageStatusConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun chatDao(): ChatDao
abstract fun userProfileDao(): UserProfileDao
companion object {
const val DATABASE_NAME = "knot_messenger.db"
}
}

View File

@@ -1,39 +0,0 @@
package chats.data.local.database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity для хранения чатов в локальной БД Room
*/
@Entity(
tableName = "chats",
indices = [
Index(value = ["remoteId"], unique = true)
]
)
data class ChatEntity(
@PrimaryKey(autoGenerate = false)
val localId: String,
val remoteId: String?, // ID с сервера
val type: String, // PRIVATE, GROUP, CHANNEL
val name: String,
val avatar: String? = null,
val unreadCount: Int = 0,
val lastMessageId: String? = null,
val lastMessageText: String? = null,
val lastMessageTimestamp: Long? = null,
val lastMessageJson: String? = null,
val updatedAtMillis: Long = System.currentTimeMillis()
)

View File

@@ -1,66 +0,0 @@
package chats.data.local.database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import chats.domain.model.MessageStatus
/**
* Entity для хранения сообщений в локальной БД Room
* Используется для офлайн-режима и Single Source of Truth паттерна
*/
@Entity(
tableName = "messages",
indices = [
Index(value = ["chatId", "sequenceId"]),
Index(value = ["localId"], unique = true),
Index(value = ["serverId"]),
Index(value = ["status"])
]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = false)
val localId: String, // UUID генерируется на клиенте при создании
val serverId: String?, // null для неотправленных сообщений
val idempotencyKey: String, // UUID v4 для идемпотентности
val chatId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String? = null,
val content: String?,
val sequenceId: Long,
val createdAt: String, // ISO-8601 формат
val mediaType: String,
val mediaJson: String, // JSON список медиа (для Room)
val reactionsJson: String = "{}", // JSON map эмодзи -> count
val status: MessageStatus,
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyToServerId: String? = null,
val errorMessage: String? = null,
val retryCount: Int = 0,
val createdAtMillis: Long = 0,
val updatedAtMillis: Long = 0
)

View File

@@ -1,17 +0,0 @@
package chats.data.local.database
import androidx.room.TypeConverter
import chats.domain.model.MessageStatus
/**
* Конвертеры для Room для работы с Enum и другими типами
*/
class MessageStatusConverter {
@TypeConverter
fun fromMessageStatus(status: MessageStatus): String = status.name
@TypeConverter
fun toMessageStatus(value: String): MessageStatus = runCatching {
MessageStatus.valueOf(value)
}.getOrDefault(MessageStatus.UNKNOWN)
}

View File

@@ -1,27 +0,0 @@
package chats.data.local.database
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Entity для хранения профиля пользователя в локальной БД Room
*/
@Entity(tableName = "user_profile")
data class UserProfileEntity(
@PrimaryKey
val userId: String,
val username: String,
val displayName: String,
val avatarUrl: String? = null,
val bio: String? = null,
val isOnline: Boolean = false,
val lastSeenMillis: Long = 0,
val updatedAtMillis: Long = System.currentTimeMillis()
)

View File

@@ -1,57 +0,0 @@
package chats.data.local.mappers
import chats.data.local.database.ChatEntity
import chats.domain.model.Chat
import com.google.gson.Gson
private val gson = Gson()
/**
* Преобразует Domain Chat в Entity
*/
fun Chat.toEntity(): ChatEntity {
val timestamp = this.lastMessage?.createdAt?.let { parseTimestamp(it) }
return ChatEntity(
localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(),
remoteId = this.id,
type = this.type,
name = this.name,
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessageText = this.lastMessage?.content,
lastMessageTimestamp = timestamp,
lastMessageJson = this.lastMessage?.let { gson.toJson(it) }
)
}
/**
* Преобразует Entity в Domain Chat
*/
fun ChatEntity.toDomain(): Chat {
val lastMessage = this.lastMessageJson?.let { json ->
try {
gson.fromJson(json, chats.domain.model.Message::class.java)
} catch (e: Exception) {
null
}
}
return Chat(
id = this.remoteId ?: this.localId,
type = this.type,
name = this.name,
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessage = lastMessage
)
}
/**
* Парсит ISO-8601 timestamp в Long (millis)
*/
private fun parseTimestamp(isoTimestamp: String): Long? {
return try {
java.time.ZonedDateTime.parse(isoTimestamp).toInstant().toEpochMilli()
} catch (e: Exception) {
null
}
}

View File

@@ -1,117 +0,0 @@
package chats.data.local.mappers
import chats.data.local.database.MessageEntity
import chats.domain.model.Message
import chats.domain.model.MessageStatus
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private val gson = Gson()
/**
* Преобразует Domain Message в Entity для сохранения в Room
*/
fun Message.toEntity(status: MessageStatus = MessageStatus.SENT): MessageEntity {
return MessageEntity(
localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(),
serverId = if (status == MessageStatus.SENT || status == MessageStatus.DELIVERED || status == MessageStatus.READ) this.id else null,
idempotencyKey = java.util.UUID.randomUUID().toString(),
chatId = this.chatId,
senderId = this.senderId,
senderName = this.senderName,
senderAvatar = this.senderAvatar,
content = this.content,
sequenceId = this.sequenceId.toLong(),
createdAt = this.createdAt,
mediaType = this.mediaType.name,
mediaJson = gson.toJson(this.media),
reactionsJson = gson.toJson(this.reactions),
status = status,
isPinned = this.isPinned,
isForwarded = this.isForwarded,
forwardedFromName = this.forwardedFromName,
replyToServerId = this.replyTo?.id,
createdAtMillis = parseTimestamp(this.createdAt),
updatedAtMillis = System.currentTimeMillis()
)
}
/**
* Преобразует Entity в Domain Message для отображения в UI
*/
fun MessageEntity.toDomain(): Message {
val mediaListType = object : TypeToken<List<chats.domain.model.Media>>() {}.type
val reactionsMapType = object : TypeToken<Map<String, Int>>() {}.type
return Message(
id = this.serverId ?: this.localId,
chatId = this.chatId,
senderId = this.senderId,
senderName = this.senderName,
senderAvatar = this.senderAvatar,
content = this.content,
sequenceId = this.sequenceId.toInt(),
createdAt = this.createdAt,
media = try {
gson.fromJson<List<chats.domain.model.Media>>(this.mediaJson, mediaListType) ?: emptyList()
} catch (e: Exception) {
emptyList()
},
mediaType = try {
chats.domain.model.MediaType.valueOf(this.mediaType)
} catch (e: Exception) {
chats.domain.model.MediaType.TEXT
},
reactions = try {
gson.fromJson<Map<String, Int>>(this.reactionsJson, reactionsMapType) ?: emptyMap()
} catch (e: Exception) {
emptyMap()
},
isRead = this.status == MessageStatus.READ,
isPinned = this.isPinned,
isForwarded = this.isForwarded,
forwardedFromName = this.forwardedFromName,
replyTo = null, // replyTo загружается отдельно если нужно
status = this.status
)
}
/**
* Создает новое локальное сообщение со статусом PENDING
*/
fun createPendingMessageEntity(
chatId: String,
senderId: String,
senderName: String,
content: String?,
mediaType: String = "TEXT",
mediaJson: String = "[]",
replyToId: String? = null
): MessageEntity {
val localId = java.util.UUID.randomUUID().toString()
return MessageEntity(
localId = localId,
serverId = null,
idempotencyKey = localId, // Используем localId как idempotency key
chatId = chatId,
senderId = senderId,
senderName = senderName,
content = content,
sequenceId = -1, // Будет обновлено после получения с сервера
createdAt = java.time.ZonedDateTime.now().toString(),
mediaType = mediaType,
mediaJson = mediaJson,
status = MessageStatus.PENDING,
replyToServerId = replyToId,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
}
private fun parseTimestamp(createdAt: String): Long {
return try {
java.time.ZonedDateTime.parse(createdAt).toInstant().toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
}
}

View File

@@ -1,43 +0,0 @@
package chats.data.local.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
/**
* PagingSource для загрузки сообщений из Room Database
*/
class MessagePagingSource(
private val chatId: String,
private val messageDao: MessageDao
) : PagingSource<Int, MessageEntity>() {
companion object {
private const val PAGE_SIZE = 30
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
return try {
val position = params.key ?: 0 // Начинаем с 0
val messages = messageDao.getMessagesPaged(
chatId = chatId,
offset = position,
limit = PAGE_SIZE
)
LoadResult.Page(
data = messages,
prevKey = if (position > 0) position - PAGE_SIZE else null,
nextKey = if (messages.isEmpty()) null else position + PAGE_SIZE
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
return state.anchorPosition
}
}

View File

@@ -1,103 +0,0 @@
package chats.data.local.paging
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.database.AppDatabase
import chats.data.local.database.MessageEntity
import chats.data.local.mappers.toEntity
import chats.data.repository.toDomain as dtoToDomain
import chats.data.remote.api.ChatApi
import chats.domain.model.MessageStatus
import core.network.ServerConfig
import core.security.TokenManager
/**
* RemoteMediator для синхронизации сообщений с сервером.
* Работает с Room через withTransaction для атомарности.
*/
@OptIn(ExperimentalPagingApi::class)
class MessageRemoteMediator(
private val chatId: String,
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val chatApi: ChatApi,
private val tokenManager: TokenManager,
private val appDatabase: AppDatabase,
private val serverConfig: ServerConfig
) : RemoteMediator<Int, MessageEntity>() {
companion object {
private const val PAGE_SIZE = 30
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, MessageEntity>
): RemoteMediator.MediatorResult {
return try {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val cursor = when (loadType) {
LoadType.REFRESH -> {
// При refresh загружаем новые сообщения после последнего локального
messageDao.getLastMessage(chatId)?.serverId
}
LoadType.PREPEND -> {
// Загружаем более старые сообщения перед первым локальным
messageDao.getFirstMessage(chatId)?.serverId
}
LoadType.APPEND -> {
// Загружаем новые сообщения после последнего локального
messageDao.getLastMessage(chatId)?.serverId
}
}
val remoteMessages = chatApi.getMessages(chatId, cursor = cursor, limit = PAGE_SIZE)
val endOfPaginationReached = remoteMessages.size < PAGE_SIZE
appDatabase.withTransaction {
if (loadType == LoadType.REFRESH) {
// При полном обновлении можно очистить старые SENT/DELIVERED/READ,
// но оставить локальные PENDING/FAILED
// messageDao.deleteMessagesByChatId(chatId) // Опционально
}
val entities = remoteMessages.map { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing != null) {
// Обновляем существующее, сохраняя localId
existing.copy(
content = dto.content,
reactionsJson = com.google.gson.Gson().toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
updatedAtMillis = System.currentTimeMillis()
)
} else {
// Создаём новое
dto.dtoToDomain(currentUserId, baseUrl).toEntity(MessageStatus.SENT).copy(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id
)
}
}
messageDao.insertMessages(entities)
}
RemoteMediator.MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: Exception) {
RemoteMediator.MediatorResult.Error(e)
}
}
override suspend fun initialize(): InitializeAction = InitializeAction.SKIP_INITIAL_REFRESH
}

View File

@@ -1,130 +0,0 @@
package chats.data.remote.api
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import retrofit2.http.*
data class SendMessageRequest(
val content: String?,
val type: String = "text",
val attachments: List<AttachmentRequest>? = null,
val replyToId: String? = null,
val quote: String? = null,
val forwardedFromId: String? = null
)
data class AttachmentRequest(
val type: String,
val url: String,
val fileName: String,
val fileSize: Long
)
interface ChatApi {
@GET("chats")
suspend fun getChats(): List<ChatDto>
@GET("messages/chat/{chatId}")
suspend fun getMessages(
@Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null,
@Query("pivot") pivot: Long? = null,
@Query("limit") limit: Int? = 50
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(
@Path("chatId") chatId: String,
@Body request: SendMessageRequest,
@Header("X-Idempotency-Key") idempotencyKey: String
): String
@Multipart
@POST("messages/upload")
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
// Klipy GIF API
@GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
@GET("klipy/search")
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
@GET("klipy/categories")
suspend fun getGifCategories(): GifCategoriesResponse
@POST("klipy/shared/{id}")
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/favorites")
suspend fun getOrCreateFavorites(): ChatDto
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@POST("chats/{chatId}/read")
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
@DELETE("messages/{messageId}")
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
@PUT("messages/{messageId}")
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): String
}
data class CreatePersonalChatRequest(
val userId: String
)
data class KlipyResponse(
val data: KlipyDataWrapper
)
data class KlipyDataWrapper(
val data: List<KlipyGifDto>
)
data class KlipyGifDto(
val id: String,
val images: GifImagesDto? = null,
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
val media_formats: Map<String, GifImageSourceDto>? = null,
val title: String? = null
)
data class GifImagesDto(
val fixed_height: GifImageSourceDto? = null,
val original: GifImageSourceDto? = null,
val fixed_height_small: GifImageSourceDto? = null
)
data class GifImageSourceDto(
val url: String
)
data class GifCategoryDto(
val category: String,
val preview_url: String,
val query: String
)
data class FileUploadResponse(
val url: String,
val filename: String,
val size: Long
)
data class GifCategoriesResponse(
val data: GifCategoriesData
)
data class GifCategoriesData(
val categories: List<GifCategoryDto>
)

View File

@@ -1,57 +0,0 @@
package chats.data.remote.dto
import com.google.gson.annotations.SerializedName
data class UserBasicDto(
@SerializedName("id") val id: String,
@SerializedName("username") val username: String? = null,
@SerializedName("displayName") val displayName: String? = null,
@SerializedName("avatarUrl") val avatarUrl: String? = null
)
data class MessageDto(
@SerializedName("id", alternate = ["Id"]) val id: String,
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
)
data class ReactionDto(
@SerializedName("emoji") val emoji: String,
@SerializedName("count") val count: Int,
@SerializedName("isSetByMe") val isSetByMe: Boolean
)
data class MediaItemDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("url") val url: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("duration") val duration: Double? = null
)
data class ChatDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)
data class ChatMemberDto(
@SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto? = null
)

View File

@@ -1,33 +0,0 @@
package chats.data.remote.signalr
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
sealed class ChatEvent {
data class NewMessage(val message: MessageDto) : ChatEvent()
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserOnline(val userId: String) : ChatEvent()
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
data class NewChat(val chat: ChatDto) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
// Call Events (WebRTC Signaling)
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
data class CallEnded(val chatId: String) : ChatEvent()
// Group Call Events
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
}

View File

@@ -1,280 +0,0 @@
package chats.data.remote.signalr
import android.util.Log
import io.reactivex.rxjava3.core.Single
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.delay
data class ReadMessagesRequest(
val chatId: String,
val lastReadMessageId: String,
val lastReadSequenceId: Int
)
data class MessagesReadEvent(
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
) {
val effectiveChatId: String get() = chatId ?: ""
val effectiveUserId: String get() = userId ?: ""
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
}
data class ReactionEvent(
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
)
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton
class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private var lastBaseUrl: String? = null
private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
lastBaseUrl = baseUrl
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken))
.build()
setupHandlers()
hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
_status.value = ConnectionStatus.DISCONNECTED
scope.launch {
delay(5000)
connect(baseUrl, accessToken)
}
}
scope.launch {
try {
hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected")
} catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED
}
}
}
private fun setupHandlers() {
hubConnection?.let { conn ->
conn.on("new_message", { message: MessageDto ->
_events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java)
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
}, String::class.java, String::class.java, String::class.java)
conn.on("message_deleted", { messageId: String, chatId: String ->
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
}, String::class.java, String::class.java)
conn.on("messages_read", { data: MessagesReadEvent ->
_events.tryEmit(ChatEvent.MessagesRead(
data.effectiveChatId,
data.effectiveUserId,
data.effectiveLastReadSequenceId
))
}, MessagesReadEvent::class.java)
conn.on("user_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_stopped_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java)
conn.on("new_chat", { chat: ChatDto ->
_events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java)
conn.on("reaction_added", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = false
))
}, ReactionEvent::class.java)
conn.on("reaction_removed", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = true
))
}, ReactionEvent::class.java)
// WebRTC Signaling Handlers
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
}, String::class.java, String::class.java, String::class.java, String::class.java)
conn.on("call_answered", { chatId: String, answer: String ->
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
}, String::class.java, String::class.java)
conn.on("ice_candidate", { chatId: String, candidate: String ->
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
}, String::class.java, String::class.java)
conn.on("call_ended", { chatId: String ->
_events.tryEmit(ChatEvent.CallEnded(chatId))
}, String::class.java)
conn.on("message_pinned", { data: Map<String, Any> ->
// The web version expects a message object, but here we might get a partial DTO or just IDs.
// Let's assume we get { chatId, message: MessageDto } based on web
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
// For simplicity, we might needs a dedicated DTO if it fails.
}, Map::class.java)
conn.on("message_pinned", { chatId: String, message: MessageDto ->
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
}, String::class.java, MessageDto::class.java)
conn.on("message_unpinned", { chatId: String, messageId: String ->
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
}, String::class.java, String::class.java)
}
}
fun disconnect() {
hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
}
fun addReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("add_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
} else {
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
}
}
fun removeReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("remove_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
}
}
fun pinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("pin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
?.subscribe()
}
}
fun unpinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("unpin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
?.subscribe()
}
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("read_messages", request)
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
}
}
fun joinChat(chatId: String) {
scope.launch {
// Wait for connection to be established if it's currently connecting
var attempts = 0
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
delay(500)
attempts++
}
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("join_chat", chatId)
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
?.subscribe()
Log.d("ChatHubClient", "Joined chat room: $chatId")
} else {
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
}
}
}
fun sendTypingIndicator(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_start", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
}
}
fun sendUserStoppedTyping(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_stop", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
}
}
}

View File

@@ -1,94 +0,0 @@
package chats.data.remote.signalr
import android.content.Context
import core.notifications.data.ActiveChatTracker
import core.notifications.data.NotificationHelper
import core.security.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
private val chatRepository: chats.domain.repository.ChatRepository,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false
private val processedMessageIds = mutableSetOf<String>()
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
} catch (e: Exception) {
// Ignore load error
}
}
}
fun start() {
if (isStarted) return
isStarted = true
// Initial count load
refresh()
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message or already processed
if (message.senderId == currentUserId) return@onEach
if (processedMessageIds.contains(message.id)) return@onEach
// Mark as processed
processedMessageIds.add(message.id)
if (processedMessageIds.size > 200) {
processedMessageIds.remove(processedMessageIds.first())
}
// Increment immediately for UI feedback
activeChatTracker.incrementUnreadCount()
// Refresh total count from source of truth in background
refresh()
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode(),
totalCount = activeChatTracker.totalUnreadCount.value
)
}
is ChatEvent.MessagesRead -> {
// If anyone read messages, sync our total count
refresh()
}
else -> Unit
}
}
.launchIn(scope)
}
}

View File

@@ -1,416 +0,0 @@
package chats.data.repository
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
import chats.data.local.mappers.createPendingMessageEntity
import chats.data.local.mappers.toDomain
import chats.data.local.mappers.toEntity
import chats.data.local.paging.MessagePagingSource
import chats.data.local.paging.MessageRemoteMediator
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.MessageDto
import chats.data.workers.ChatSyncWorker
import chats.data.workers.SendMessageWorker
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MessageStatus
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject
/**
* Реализация ChatRepository с поддержкой офлайн-режима.
* Использует паттерн Single Source of Truth: UI всегда берет данные из Room.
*/
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val workManager: WorkManager,
private val appDatabase: chats.data.local.database.AppDatabase,
private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository {
private val gson = com.google.gson.Gson()
// ==================== Чаты ====================
override fun getChatsFlow(): Flow<List<Chat>> {
// Single Source of Truth - данные из Room
return chatDao.getAllChats().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val gson = com.google.gson.Gson()
return try {
// Пробуем получить с сервера
val remoteChats = api.getChats()
val domainChats = remoteChats.map { it.toDomain(currentUserId, baseUrl) }
// Сохраняем в локальную БД
val entities = domainChats.map { chat ->
val existing = chatDao.getChatByRemoteId(chat.id)
chat.toEntity().copy(
localId = existing?.localId ?: java.util.UUID.randomUUID().toString()
)
}
chatDao.insertChats(entities)
domainChats
} catch (e: Exception) {
Log.w("ChatRepo", "Failed to fetch chats from server, returning local", e)
// При ошибке возвращаем локальные данные
emptyList()
}
}
override suspend fun syncChats() {
try {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val remoteChats = api.getChats()
val gson = com.google.gson.Gson()
val entities = remoteChats.map { dto ->
val existing = chatDao.getChatByRemoteId(dto.id)
val lastMessage = dto.messages.firstOrNull()
val timestamp = lastMessage?.createdAt?.let {
try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() }
catch (e: Exception) { null }
}
val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl)
dto.toDomain(currentUserId, baseUrl).toEntity().copy(
localId = existing?.localId ?: java.util.UUID.randomUUID().toString(),
lastMessageText = lastMessage?.content,
lastMessageTimestamp = timestamp,
lastMessageJson = lastMessageDomain?.let { gson.toJson(it) }
)
}
chatDao.insertChats(entities)
} catch (e: Exception) {
Log.e("ChatRepo", "Sync chats failed", e)
}
}
// ==================== Сообщения ====================
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
// Single Source of Truth - всегда из Room
return messageDao.getMessagesByChatId(chatId).map { entities ->
entities.map { it.toDomain() }
}
}
/**
* Получает сообщения с помощью Paging 3 и RemoteMediator
*/
@OptIn(ExperimentalPagingApi::class)
override fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>> {
return Pager(
config = PagingConfig(
pageSize = 30,
prefetchDistance = 10,
enablePlaceholders = false,
initialLoadSize = 60
),
pagingSourceFactory = {
MessagePagingSource(chatId, messageDao)
},
remoteMediator = MessageRemoteMediator(
chatId = chatId,
messageDao = messageDao,
chatDao = chatDao,
chatApi = api,
tokenManager = tokenManager,
appDatabase = appDatabase,
serverConfig = serverConfig
)
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
}
}
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = tokenManager.getUserId() ?: ""
return try {
Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
if (messages.isNotEmpty()) {
Log.d("ChatRepo", "Received ${messages.size} messages")
// Сохраняем в локальную БД
saveMessagesToLocal(messages, chatId, currentUserId)
}
messages.map { msg ->
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
}
} catch (e: Exception) {
Log.e("ChatRepo", "Fetch messages failed, returning local", e)
emptyList()
}
}
override suspend fun sendMessage(
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?,
forwardedFromId: String?
): Message {
val userId = tokenManager.getUserId() ?: ""
val userName = tokenManager.getUsername() ?: userId
// 1. Создаем локальное сообщение со статусом PENDING
val mediaType = when (type) {
"image" -> "IMAGE"
"video" -> "VIDEO"
"audio", "voice" -> "AUDIO"
else -> "TEXT"
}
val mediaJson = attachments?.map {
chats.domain.model.Media(
id = java.util.UUID.randomUUID().toString(),
type = it.type,
url = it.url,
filename = it.fileName,
size = it.fileSize
)
}?.let { gson.toJson(it) } ?: "[]"
val pendingEntity = createPendingMessageEntity(
chatId = chatId,
senderId = userId,
senderName = userName,
content = content,
mediaType = mediaType,
mediaJson = mediaJson,
replyToId = replyToId
)
// 2. Сохраняем в Room
messageDao.insertMessage(pendingEntity)
// 3. Ставим задачу в WorkManager для отправки
val workRequest = SendMessageWorker.createWorkRequest(pendingEntity.localId)
workManager.enqueueUniqueWork(
"send_${pendingEntity.localId}",
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.d("ChatRepo", "Queued message for sending: ${pendingEntity.localId}")
// 4. Возвращаем доменную модель для немедленного отображения в UI
return pendingEntity.toDomain()
}
override suspend fun retryFailedMessage(localId: String) {
val message = messageDao.getMessageByLocalId(localId)
?: return
if (message.status != MessageStatus.FAILED) return
// Сбрасываем статус и ставим в очередь
messageDao.updateMessageStatus(
localId = localId,
status = MessageStatus.PENDING,
updatedAtMillis = System.currentTimeMillis()
)
val workRequest = SendMessageWorker.createWorkRequest(localId)
workManager.enqueueUniqueWork(
"send_$localId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessageByLocalId(messageId)
}
override suspend fun saveMessage(message: Message) {
// Сохраняем входящее сообщение из SignalR
val entity = message.toEntity(MessageStatus.DELIVERED)
messageDao.insertMessage(entity)
}
// ==================== Синхронизация ====================
override suspend fun syncMessagesForChat(chatId: String) {
val workRequest = ChatSyncWorker.createOneTimeWorkRequest(chatId)
workManager.enqueue(workRequest)
}
override suspend fun schedulePeriodicSync() {
val workRequest = ChatSyncWorker.createPeriodicWorkRequest()
workManager.enqueueUniquePeriodicWork(
"periodic_chat_sync",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}
// ==================== Вспомогательные методы ====================
private suspend fun saveMessagesToLocal(messages: List<chats.data.remote.dto.MessageDto>, chatId: String, currentUserId: String) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing == null) {
val entity = MessageEntity(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id,
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" },
content = dto.content,
sequenceId = dto.sequenceId?.toLong() ?: 0L,
createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(),
mediaType = dto.type ?: "TEXT",
mediaJson = gson.toJson(dto.media.map { mediaItem ->
chats.domain.model.Media(
id = mediaItem.id,
type = mediaItem.type,
url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" },
filename = mediaItem.filename,
size = mediaItem.size,
duration = mediaItem.duration
)
}),
reactionsJson = gson.toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
messageDao.insertMessage(entity)
}
}
}
// ==================== Остальные методы ====================
override suspend fun addReaction(messageId: String, emoji: String) {
try {
api.addReaction(messageId, emoji)
} catch (e: Exception) {
Log.e("ChatRepo", "Add reaction failed", e)
throw e
}
}
override suspend fun sendTypingStatus(chatId: String) {
try {
api.sendTypingStatus(chatId)
} catch (e: Exception) {
// Игнорируем ошибки typing status
}
}
override suspend fun resetUnreadCount(chatId: String) {
chatDao.updateUnreadCount(chatId, 0)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) {
Log.e("ChatRepo", "Error marking messages as read", e)
}
}
override suspend fun uploadMedia(file: java.io.File): String {
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"mp3", "m4a", "wav" -> "audio/mpeg"
else -> "application/octet-stream"
}
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url
}
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.getTrendingGifs(page).data.data
}
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.searchGifs(query, page).data.data
}
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
override suspend fun getOrCreateFavorites(): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val dto = api.getOrCreateFavorites()
val chat = dto.toDomain(currentUserId, baseUrl)
chatDao.insertChat(chat.toEntity())
return chat
}
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val returnedId = api.editMessage(messageId, request)
return Message(
id = returnedId,
chatId = "",
senderId = currentUserId,
senderName = "",
content = content,
sequenceId = 0,
createdAt = java.time.ZonedDateTime.now().toString()
)
}
}

View File

@@ -1,83 +0,0 @@
package chats.data.repository
import chats.data.remote.dto.*
import chats.domain.model.*
// Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
val chatName = name ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
} else "Group Chat"
val chatAvatar = (avatar ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
} else null)?.ensureAbsoluteUrl(baseUrl)
return Chat(
id = id,
type = type,
name = chatName,
avatar = chatAvatar,
unreadCount = unreadCount,
lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
)
}
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
val firstMedia = media.firstOrNull()
val isVoiceFromFilename = firstMedia?.filename?.endsWith(".mp3", ignoreCase = true) == true ||
firstMedia?.filename?.startsWith("voice_", ignoreCase = true) == true
val domainMediaType = when (type) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE
else -> when (firstMedia?.type) {
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> if (isVoiceFromFilename) MediaType.AUDIO else MediaType.FILE
else -> MediaType.TEXT
}
}
return Message(
id = id,
chatId = chatId ?: "",
senderId = senderId ?: "",
senderName = sender?.displayName ?: "Unknown",
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content,
sequenceId = sequenceId ?: 0,
createdAt = createdAt ?: "",
media = media.map {
chats.domain.model.Media(
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = if (isVoiceFromFilename) "voice" else (it.type ?: "unknown"),
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
)
},
mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
isRead = senderId == currentUserId,
isPinned = isPinned ?: false,
isForwarded = forwardedFromId != null,
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
replyTo = replyTo?.toDomain(currentUserId, baseUrl)
)
}
fun String.ensureAbsoluteUrl(baseUrl: String): String {
return if (this.startsWith("http")) {
this
} else {
val base = baseUrl.removeSuffix("/")
val path = if (this.startsWith("/")) this else "/$this"
"$base$path"
}
}

View File

@@ -1,227 +0,0 @@
package chats.data.workers
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.remote.dto.*
import chats.data.repository.toDomain
import chats.domain.model.MessageStatus
import core.security.TokenManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
/**
* Worker для периодической синхронизации чатов и сообщений.
* Запускается при наличии сети для получения новых сообщений с сервера.
*/
@HiltWorker
class ChatSyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val chatApi: chats.data.remote.api.ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: core.network.ServerConfig
) : CoroutineWorker(context, params) {
companion object {
const val WORK_TAG = "chat_sync_worker"
const val KEY_CHAT_ID = "chat_id"
const val KEY_CURSOR = "cursor"
/**
* Создает периодический запрос на синхронизацию всех чатов
*/
fun createPeriodicWorkRequest(): PeriodicWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
return PeriodicWorkRequestBuilder<ChatSyncWorker>(
repeatInterval = 15,
repeatIntervalTimeUnit = TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(WORK_TAG)
.build()
}
/**
* Создает разовый запрос на синхронизацию конкретного чата
*/
fun createOneTimeWorkRequest(chatId: String? = null, cursor: String? = null): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = workDataOf(
KEY_CHAT_ID to chatId,
KEY_CURSOR to cursor
)
return OneTimeWorkRequestBuilder<ChatSyncWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.addTag(WORK_TAG)
.build()
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.d("ChatSyncWorker", "Starting chat sync")
try {
// 1. Синхронизируем список чатов
syncChats()
// 2. Если указан chatId - синхронизируем сообщения
val chatId = inputData.getString(KEY_CHAT_ID)
if (chatId != null) {
syncMessagesForChat(chatId)
}
Log.i("ChatSyncWorker", "Sync completed successfully")
Result.success()
} catch (e: IOException) {
Log.w("ChatSyncWorker", "Network error during sync: ${e.message}")
Result.retry()
} catch (e: SocketTimeoutException) {
Log.w("ChatSyncWorker", "Timeout during sync: ${e.message}")
Result.retry()
} catch (e: Exception) {
Log.e("ChatSyncWorker", "Error during sync", e)
Result.failure()
}
}
private suspend fun syncChats() {
val remoteChats = chatApi.getChats()
val localChats = mutableListOf<chats.data.local.database.ChatEntity>()
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val gson = com.google.gson.Gson()
remoteChats.forEach { dto ->
val existingChat = chatDao.getChatByRemoteId(dto.id)
val lastMessage = dto.messages.firstOrNull()
val timestamp = lastMessage?.createdAt?.let {
try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() }
catch (e: Exception) { null }
}
val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl)
val chatEntity = chats.data.local.database.ChatEntity(
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
remoteId = dto.id,
type = dto.type,
name = dto.name ?: "",
avatar = dto.avatar,
unreadCount = dto.unreadCount,
lastMessageText = lastMessage?.content,
lastMessageTimestamp = timestamp,
lastMessageJson = lastMessageDomain?.let { gson.toJson(it) }
)
localChats.add(chatEntity)
}
// Если сервер не вернул Избранное — создаём/получаем его отдельно
val hasFavorites = remoteChats.any { it.type == "favorites" }
if (!hasFavorites) {
try {
val favDto = chatApi.getOrCreateFavorites()
val existingChat = chatDao.getChatByRemoteId(favDto.id)
val favTimestamp = favDto.messages.firstOrNull()?.createdAt?.let {
try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } catch (e: Exception) { null }
}
val favLastMessageDomain = favDto.messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
val favEntity = chats.data.local.database.ChatEntity(
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
remoteId = favDto.id,
type = favDto.type,
name = favDto.name ?: "Избранное",
avatar = favDto.avatar,
unreadCount = favDto.unreadCount,
lastMessageText = favDto.messages.firstOrNull()?.content,
lastMessageTimestamp = favTimestamp,
lastMessageJson = favLastMessageDomain?.let { gson.toJson(it) }
)
localChats.add(favEntity)
Log.d("ChatSyncWorker", "Created favorites chat: ${favDto.id}")
} catch (e: Exception) {
Log.e("ChatSyncWorker", "Failed to get or create favorites", e)
}
}
chatDao.insertChats(localChats)
Log.d("ChatSyncWorker", "Synced ${localChats.size} chats")
}
private suspend fun syncMessagesForChat(chatId: String) {
val lastMessage = messageDao.getMessagesByStatus(
chatId = chatId,
statuses = listOf(MessageStatus.SENT, MessageStatus.DELIVERED, MessageStatus.READ)
).maxByOrNull { it.sequenceId }
val cursor = lastMessage?.serverId
val messages = chatApi.getMessages(chatId, cursor = cursor, limit = 50)
if (messages.isNotEmpty()) {
saveMessagesToLocal(messages, chatId)
Log.d("ChatSyncWorker", "Synced ${messages.size} messages for chat $chatId")
}
}
private suspend fun saveMessagesToLocal(
messages: List<chats.data.remote.dto.MessageDto>,
chatId: String
) {
val currentUserId = tokenManager.getUserId() ?: ""
val gson = com.google.gson.Gson()
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing == null) {
val entity = chats.data.local.database.MessageEntity(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id,
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" },
content = dto.content,
sequenceId = dto.sequenceId?.toLong() ?: 0L,
createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(),
mediaType = dto.type ?: "TEXT",
mediaJson = gson.toJson(dto.media.map { mediaItem ->
chats.domain.model.Media(
id = mediaItem.id,
type = mediaItem.type,
url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" },
filename = mediaItem.filename,
size = mediaItem.size,
duration = mediaItem.duration
)
}),
reactionsJson = gson.toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
messageDao.insertMessage(entity)
}
}
}
}

View File

@@ -1,170 +0,0 @@
package chats.data.workers
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
import chats.data.remote.api.AttachmentRequest
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.domain.model.MessageStatus
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
/**
* Worker для отправки сообщений с поддержкой офлайн-режима.
* Использует WorkManager для гарантированной доставки при появлении сети.
*/
@HiltWorker
class SendMessageWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val messageDao: MessageDao,
private val chatApi: ChatApi
) : CoroutineWorker(context, params) {
companion object {
const val WORK_TAG = "send_message_worker"
const val KEY_LOCAL_ID = "local_id"
const val MAX_RETRY_COUNT = 5
/**
* Создает запрос на отправку сообщения через WorkManager
*/
fun createWorkRequest(localId: String): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = workDataOf(KEY_LOCAL_ID to localId)
return OneTimeWorkRequestBuilder<SendMessageWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.addTag(WORK_TAG)
.addTag("$WORK_TAG:$localId")
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val localId = inputData.getString(KEY_LOCAL_ID)
?: return@withContext Result.failure()
Log.d("SendMessageWorker", "Starting work for message: $localId")
try {
// Получаем сообщение из локальной БД
val message = messageDao.getMessageByLocalId(localId)
?: return@withContext Result.failure(
workDataOf("error" to "Message not found: $localId")
)
// Проверяем, не отправлено ли уже
if (message.status == MessageStatus.SENT || message.status == MessageStatus.DELIVERED || message.status == MessageStatus.READ) {
Log.d("SendMessageWorker", "Message already sent: $localId")
return@withContext Result.success()
}
// Проверяем лимит повторных попыток
if (message.retryCount >= MAX_RETRY_COUNT) {
Log.w("SendMessageWorker", "Max retries reached for message: $localId")
messageDao.updateMessageError(
localId = localId,
status = MessageStatus.FAILED,
errorMessage = "Max retry count exceeded",
updatedAtMillis = System.currentTimeMillis()
)
return@withContext Result.failure(
workDataOf("error" to "Max retries reached")
)
}
// Обновляем статус на SENDING
messageDao.updateMessageStatus(
localId = localId,
status = MessageStatus.SENDING,
updatedAtMillis = System.currentTimeMillis()
)
// Выполняем запрос к API с Idempotency-Key
// Десериализуем media из JSON
val mediaList = try {
val gson = com.google.gson.Gson()
val type = object : com.google.gson.reflect.TypeToken<List<chats.domain.model.Media>>() {}.type
gson.fromJson<List<chats.domain.model.Media>>(message.mediaJson, type) ?: emptyList()
} catch (e: Exception) {
emptyList<chats.domain.model.Media>()
}
val attachments = mediaList.map {
AttachmentRequest(
type = it.type,
url = it.url,
fileName = it.filename ?: "",
fileSize = it.size ?: 0L
)
}.takeIf { it.isNotEmpty() }
val request = SendMessageRequest(
content = message.content,
type = message.mediaType.lowercase(),
attachments = attachments,
replyToId = message.replyToServerId
)
val serverId = chatApi.sendMessage(
chatId = message.chatId,
request = request,
idempotencyKey = message.idempotencyKey
)
// Успешная отправка - обновляем статус и serverId
messageDao.updateMessageWithServerId(
localId = localId,
serverId = serverId,
status = MessageStatus.SENT,
updatedAtMillis = System.currentTimeMillis()
)
Log.i("SendMessageWorker", "Message sent successfully: local=$localId, server=$serverId")
Result.success()
} catch (e: IOException) {
// Сетевая ошибка - повторяем позже
Log.w("SendMessageWorker", "Network error for message $localId: ${e.message}")
Result.retry()
} catch (e: SocketTimeoutException) {
Log.w("SendMessageWorker", "Timeout for message $localId: ${e.message}")
Result.retry()
} catch (e: Exception) {
// Остальные ошибки - помечаем как FAILED
Log.e("SendMessageWorker", "Error sending message $localId", e)
try {
messageDao.updateMessageError(
localId = localId,
status = MessageStatus.FAILED,
errorMessage = e.message ?: "Unknown error",
updatedAtMillis = System.currentTimeMillis()
)
} catch (dbError: Exception) {
Log.e("SendMessageWorker", "Failed to update error status", dbError)
}
Result.failure(
workDataOf("error" to (e.message ?: "Unknown error"))
)
}
}
}

View File

@@ -1,37 +0,0 @@
package chats.di
import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ChatModule {
@Provides
@Singleton
fun provideChatApi(retrofit: Retrofit): ChatApi {
return retrofit.create(ChatApi::class.java)
}
@Provides
@Singleton
fun provideChatRepository(
impl: ChatRepositoryImpl
): ChatRepository = impl
@Provides
@Singleton
fun provideChatHubClient(): ChatHubClient {
return ChatHubClient()
}
}

View File

@@ -1,50 +0,0 @@
package chats.di
import android.content.Context
import androidx.room.Room
import androidx.work.WorkManager
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.dao.UserProfileDao
import chats.data.local.database.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Hilt модуль для предоставления зависимостей локальной базы данных и WorkManager
*/
@Module
@InstallIn(SingletonComponent::class)
object LocalDatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME
)
.fallbackToDestructiveMigration()
.build()
}
@Provides
fun provideMessageDao(database: AppDatabase): MessageDao = database.messageDao()
@Provides
fun provideChatDao(database: AppDatabase): ChatDao = database.chatDao()
@Provides
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager {
return WorkManager.getInstance(context)
}
}

View File

@@ -1,13 +0,0 @@
package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Chat(
val id: String,
val type: String,
val name: String,
val avatar: String?,
val unreadCount: Int,
val lastMessage: Message?
)

View File

@@ -1,40 +0,0 @@
package chats.domain.model
import androidx.compose.runtime.Immutable
import chats.domain.model.MessageStatus
@Immutable
data class Message(
val id: String,
val chatId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String? = null,
val content: String?,
val sequenceId: Int,
val createdAt: String,
val media: List<Media> = emptyList(),
val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false,
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyTo: Message? = null,
val status: MessageStatus = MessageStatus.SENT // Новое поле для офлайн-статуса
)
@Immutable
data class Media(
val id: String,
val type: String,
val url: String,
val filename: String? = null,
val size: Long? = null,
val duration: Double? = null
)
enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
}

View File

@@ -1,30 +0,0 @@
package chats.domain.model
/**
* Статусы сообщения для отображения в UI и синхронизации
*/
enum class MessageStatus {
/** Сообщение создано локально, ожидает отправки */
PENDING,
/** Начата отправка на сервер */
SENDING,
/** Сообщение успешно отправлено на сервер */
SENT,
/** Сообщение доставлено получателю */
DELIVERED,
/** Сообщение прочитано получателем */
READ,
/** Ошибка отправки */
FAILED,
/** Удалено локально */
DELETED,
/** Неизвестный статус */
UNKNOWN
}

View File

@@ -1,55 +0,0 @@
package chats.domain.repository
import androidx.paging.PagingData
import chats.domain.model.Chat
import chats.domain.model.Message
import kotlinx.coroutines.flow.Flow
interface ChatRepository {
// Чаты
fun getChatsFlow(): Flow<List<Chat>>
suspend fun getChats(): List<Chat>
suspend fun syncChats()
// Сообщения - Single Source of Truth через Flow
fun getMessagesFlow(chatId: String): Flow<List<Message>>
fun getMessagesPagingSource(chatId: String): Flow<PagingData<Message>>
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
// Отправка сообщений с поддержкой офлайн
suspend fun sendMessage(
chatId: String,
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
replyToId: String? = null,
forwardedFromId: String? = null
): Message
suspend fun retryFailedMessage(localId: String)
suspend fun syncMessagesForChat(chatId: String)
suspend fun schedulePeriodicSync()
// Реакции
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
suspend fun resetUnreadCount(chatId: String)
// Локальное хранение
suspend fun saveMessage(message: Message)
suspend fun deleteLocalMessage(messageId: String)
// Медиа
suspend fun uploadMedia(file: java.io.File): String
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
// Управление чатами
suspend fun getOrCreateFavorites(): Chat
suspend fun createPersonalChat(userId: String): Chat
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
suspend fun editMessage(messageId: String, content: String): Message
}

View File

@@ -1,24 +0,0 @@
package chats.domain.usecase
import chats.data.remote.api.ChatApi
import chats.data.remote.api.FileUploadResponse
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import javax.inject.Inject
class UploadMediaUseCase @Inject constructor(
private val api: ChatApi
) {
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
return try {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
val response = api.uploadFile(body)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -1,793 +0,0 @@
package chats.presentation.chat_detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.util.Log
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.toDomain
import chats.domain.model.Message
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto
import chats.domain.model.MessageStatus
data class ChatDetailState(
val messages: List<Message> = emptyList(),
val chatName: String? = null,
val chatAvatar: String? = null,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val isTyping: Boolean = false,
val typingUser: String? = null,
val error: String? = null,
val canCall: Boolean = true,
val maxFileSize: Long = 100 * 1024 * 1024,
val trendingGifs: List<KlipyGifDto> = emptyList(),
val searchedGifs: List<KlipyGifDto> = emptyList(),
val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false,
val initialScrollIndex: Int? = null,
val pendingAttachments: List<File> = emptyList(),
val isUploading: Boolean = false,
val isCompressionEnabled: Boolean = true,
val inputText: String = "",
val replyingMessage: Message? = null,
val editingMessage: Message? = null,
val forwardingMessages: List<Message> = emptyList(),
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
val selectedMessageIds: Set<String> = emptySet(),
val pinnedMessages: List<Message> = emptyList(),
val isOffline: Boolean = false
)
@HiltViewModel
class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState())
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
private var currentChatId: String? = null
private var typingTimerJob: Job? = null
private var signalrEventsJob: Job? = null
private var lastTypingSentTime: Long = 0
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
init {
val config = serverConfig.getServerConfig()
val savedCompression = prefs.getBoolean("compression_enabled", true)
_state.update { it.copy(
canCall = config.features.calls,
maxFileSize = config.limits.maxFileSize,
isCompressionEnabled = savedCompression
) }
}
fun toggleCompression() {
_state.update { currentState ->
val newValue = !currentState.isCompressionEnabled
prefs.edit().putBoolean("compression_enabled", newValue).apply()
currentState.copy(isCompressionEnabled = newValue)
}
}
fun getCurrentUserId(): String {
return tokenManager.getUserId() ?: ""
}
private var messagesFlowJob: Job? = null
fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId
activeChatTracker.setChatId(chatId)
_state.update { it.copy(
messages = emptyList(),
isLoading = true,
initialScrollIndex = null
) }
// Ensure SignalR is connected and join the chat room
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
signalrClient.joinChat(chatId)
}
loadChatInfo(chatId)
observeSignalREvents(chatId)
// Подписываемся на Flow из Room (Single Source of Truth)
observeMessagesFromLocal(chatId)
// Немедленно грузим историю с сервера, чтобы не ждать WorkManager
viewModelScope.launch {
try {
repository.getMessages(chatId, limit = 50)
} catch (e: Exception) {
android.util.Log.e("ChatDetailVM", "Initial messages load failed", e)
}
}
// Дополнительно ставим фоновую синхронизацию через WorkManager
viewModelScope.launch {
repository.syncMessagesForChat(chatId)
}
}
/**
* Подписка на локальные сообщения из Room (Single Source of Truth)
*/
private fun observeMessagesFromLocal(chatId: String) {
messagesFlowJob?.cancel()
messagesFlowJob = viewModelScope.launch {
repository.getMessagesFlow(chatId)
.catch { e ->
Log.e("ChatDetailVM", "Error observing messages", e)
_state.update { it.copy(isLoading = false, error = e.message) }
}
.collect { messages ->
val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { currentState ->
currentState.copy(
messages = sortedMessages,
isLoading = false,
initialScrollIndex = if (currentState.initialScrollIndex == null) 0 else currentState.initialScrollIndex
)
}
}
}
}
fun onInitialScrollDone() {
_state.update { it.copy(initialScrollIndex = -1) }
}
fun loadMoreMessages() {
val chatId = currentChatId ?: return
if (_state.value.isLoading || _state.value.isLoadingMore) return
val oldestMsg = _state.value.messages.lastOrNull() ?: return
viewModelScope.launch {
_state.update { it.copy(isLoadingMore = true) }
try {
android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}")
val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString())
if (moreMessages.isNotEmpty()) {
val newSorted = moreMessages.sortedByDescending { it.sequenceId }
_state.update { currentState ->
// Избегаем дубликатов
val existingIds = currentState.messages.map { it.id }.toSet()
val uniqueMore = newSorted.filter { it.id !in existingIds }
currentState.copy(messages = currentState.messages + uniqueMore)
}
}
} finally {
_state.update { it.copy(isLoadingMore = false) }
}
}
}
private fun loadChatInfo(chatId: String) {
viewModelScope.launch {
try {
val chats = repository.getChats()
val chat = chats.find { it.id == chatId }
chat?.let { c ->
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
}
} catch (e: Exception) {
// Ignore info load error
}
}
}
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
// For handling reaction removed event
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
val count = currentReactions[emoji] ?: 0
if (count > 1) {
currentReactions[emoji] = count - 1
} else {
currentReactions.remove(emoji)
}
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
private fun observeSignalREvents(chatId: String) {
signalrEventsJob?.cancel()
signalrEventsJob = signalrClient.events
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
.filter { event ->
val eventChatId = when(event) {
is ChatEvent.NewMessage -> event.message.chatId
is ChatEvent.ReactionUpdated -> event.chatId
is ChatEvent.UserTyping -> event.chatId
is ChatEvent.MessagesRead -> event.chatId
else -> null
}
if (eventChatId == null) return@filter false
val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId)
if (match) {
android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event")
}
match
}
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
_state.update { currentState ->
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
}
}
is ChatEvent.ReactionUpdated -> {
if (event.isRemoved) {
removeMessageReaction(event.messageId, event.userId, event.emoji)
} else {
updateMessageReaction(event.messageId, event.userId, event.emoji)
}
}
is ChatEvent.MessageDeleted -> {
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
}
}
is ChatEvent.MessageEdited -> {
_state.update { currentState ->
val updated = currentState.messages.map { msg ->
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
}
currentState.copy(messages = updated)
}
}
is ChatEvent.UserTyping -> {
_state.update { it.copy(isTyping = true) }
typingTimerJob?.cancel()
typingTimerJob = viewModelScope.launch {
delay(3000)
_state.update { it.copy(isTyping = false) }
}
}
is ChatEvent.UserStoppedTyping -> {
_state.update { it.copy(isTyping = false) }
}
is ChatEvent.MessagesRead -> {
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
if (msg.sequenceId <= event.lastReadSequenceId) {
msg.copy(isRead = true)
} else msg
}
currentState.copy(messages = updatedMessages)
}
}
is ChatEvent.MessagePinned -> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s ->
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
}
}
is ChatEvent.MessageUnpinned -> {
_state.update { s ->
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
}
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
fun clearChatId() {
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
signalrEventsJob?.cancel()
signalrEventsJob = null
currentChatId = null
activeChatTracker.setChatId(null)
_state.update { it.copy(messages = emptyList(), isLoading = false) }
}
fun markAsRead() {
val chatId = currentChatId ?: return
val messages = _state.value.messages
if (messages.isEmpty()) return
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
val currentUserId = getCurrentUserId()
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
viewModelScope.launch {
try {
// Мгновенно обновляем в памяти для "галочек"
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
msg.copy(isRead = true)
} else msg
}
currentState.copy(messages = updatedMessages)
}
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
repository.resetUnreadCount(chatId)
signalrNotificationObserver.refresh()
} catch (e: Exception) {
// Ignore
}
}
}
fun onInputTextChanged(text: String) {
_state.update { it.copy(inputText = text) }
val chatId = currentChatId ?: return
// Отправляем индикатор набора текста
val now = System.currentTimeMillis()
if (now - lastTypingSentTime > 1000) {
signalrClient.sendTypingIndicator(chatId)
lastTypingSentTime = now
// Отправляем "остановился печатать" через 3 секунды
typingTimerJob?.cancel()
typingTimerJob = viewModelScope.launch {
delay(3000)
signalrClient.sendUserStoppedTyping(chatId)
}
}
}
fun onReply(message: Message) {
_state.update { it.copy(replyingMessage = message) }
}
fun forwardMessages(targetChatId: String, messages: List<Message>) {
viewModelScope.launch {
messages.forEach { msg ->
repository.sendMessage(
chatId = targetChatId,
content = msg.content,
type = msg.mediaType.name.lowercase(),
forwardedFromId = msg.senderId
)
}
}
}
fun onForward(message: Message) {
_state.update { it.copy(forwardingMessages = listOf(message)) }
loadChatsForForwarding()
}
fun loadChatsForForwarding() {
viewModelScope.launch {
try {
val chats = repository.getChats()
_state.update { it.copy(availableChatsToForward = chats) }
} catch (e: Exception) {
// Ignore
}
}
}
fun cancelForwarding() {
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
}
fun onForwardSelectedMessages() {
val selectedIds = _state.value.selectedMessageIds
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
_state.update { it.copy(forwardingMessages = messages) }
loadChatsForForwarding()
}
fun toggleSelection(messageId: String) {
_state.update { s ->
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
s.selectedMessageIds - messageId
} else {
s.selectedMessageIds + messageId
}
s.copy(selectedMessageIds = newSelection)
}
}
fun clearSelection() {
_state.update { it.copy(selectedMessageIds = emptySet()) }
}
fun cancelReply() {
_state.update { it.copy(replyingMessage = null) }
}
fun cancelEdit() {
_state.update { it.copy(editingMessage = null, inputText = "") }
}
fun deleteMessage(message: Message, forEveryone: Boolean) {
viewModelScope.launch {
try {
repository.deleteMessage(message.id, forEveryone)
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
}
} catch (e: Exception) {
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
}
}
}
fun onEdit(message: Message) {
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
}
fun pinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.pinMessage(messageId, chatId)
}
fun unpinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.unpinMessage(messageId, chatId)
}
fun onPin(message: Message) {
pinMessage(message.id)
}
fun sendMessage(onFail: (String) -> Unit = {}) {
val chatId = currentChatId ?: return
val text = _state.value.inputText
val pending = _state.value.pendingAttachments
val replyToId = _state.value.replyingMessage?.id
val editingMsg = _state.value.editingMessage
if (text.isBlank() && pending.isEmpty()) return
// Handle Edit
if (editingMsg != null) {
_state.update { it.copy(inputText = "", editingMessage = null) }
viewModelScope.launch {
try {
val updated = repository.editMessage(editingMsg.id, text)
_state.update { currentState ->
val updatedList = currentState.messages.map {
if (it.id == updated.id) updated else it
}
currentState.copy(messages = updatedList)
}
} catch (e: Exception) {
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
onFail(text)
}
}
return
}
// Clear input immediately для отзывчивого UI
_state.update { it.copy(inputText = "", replyingMessage = null, pendingAttachments = emptyList()) }
// Определяем тип медиа
val mediaType = when {
pending.isEmpty() -> "text"
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> "image"
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> "video"
pending.any { it.extension.lowercase() in listOf("mp3", "m4a", "wav") } -> "audio"
else -> "text"
}
viewModelScope.launch {
try {
// Upload attachments если есть
val attachmentRequests = if (pending.isNotEmpty()) {
_state.update { it.copy(isUploading = true) }
val requests = pending.map { file ->
val url = repository.uploadMedia(file)
chats.data.remote.api.AttachmentRequest(
type = when {
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
else -> "file"
},
url = url,
fileName = file.name,
fileSize = file.length()
)
}
_state.update { it.copy(isUploading = false) }
requests
} else {
null
}
// Отправляем сообщение - теперь оно создается локально и ставится в очередь
repository.sendMessage(
chatId = chatId,
content = if (text.isBlank()) null else text,
type = mediaType,
attachments = attachmentRequests,
replyToId = replyToId
)
// Сообщение автоматически появится в UI через Flow из Room
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
onFail(text)
}
}
}
/**
* Повторная отправка неудачного сообщения
*/
fun retryMessage(messageId: String) {
viewModelScope.launch {
repository.retryFailedMessage(messageId)
}
}
/**
* Удаление локального сообщения (отмена отправки)
*/
fun cancelMessage(messageId: String) {
viewModelScope.launch {
repository.deleteLocalMessage(messageId)
}
}
fun addReaction(messageId: String, emoji: String) {
val chatId = currentChatId ?: return
// We rely on SignalR event to update the count to avoid double counting
// especially since domain Message doesn't track user IDs for reactions yet.
signalrClient.addReaction(messageId, chatId, emoji)
}
fun sendVoiceMessage(file: File) {
android.util.Log.d("ChatDetailVM", "sendVoiceMessage called with file: ${file.absolutePath}, size: ${file.length()}")
val chatId = currentChatId ?: run {
android.util.Log.e("ChatDetailVM", "sendVoiceMessage failed: currentChatId is null")
return
}
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
viewModelScope.launch {
try {
android.util.Log.d("ChatDetailVM", "Starting voice upload...")
val url = repository.uploadMedia(file)
android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url")
val attachment = chats.data.remote.api.AttachmentRequest(
type = "voice",
url = url,
fileName = file.name,
fileSize = file.length()
)
android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...")
repository.sendMessage(
chatId = chatId,
content = null,
type = "media",
attachments = listOf(attachment),
replyToId = replyToId
)
// Сообщение автоматически появится в UI через Flow из Room
android.util.Log.d("ChatDetailVM", "Voice message queued successfully")
} catch (e: Exception) {
android.util.Log.e("ChatDetailVM", "Error sending voice message", e)
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
}
}
}
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
viewModelScope.launch {
val mimeType = context.contentResolver.getType(uri) ?: ""
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
ImageUtils.compressImage(context, uri)
} else {
copyUriToFile(context, uri)
}
file?.let { f ->
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
}
}
}
fun formatDateHeader(dateString: String): String {
return try {
if (dateString.isBlank()) return "Unknown"
// Парсим ISO 8001 (например 2024-04-14T20:56:00Z)
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(dateString) ?: return dateString
// Форматируем в локальное время: "14 апреля"
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
} catch (e: Exception) {
android.util.Log.e("ChatDetailVM", "formatDateHeader error: ${e.message}", e)
dateString.take(10)
}
}
fun getLocalDateString(isoDate: String): String {
return try {
if (isoDate.isBlank()) return "1970-01-01"
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(isoDate) ?: return isoDate.take(10)
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
} catch (e: Exception) {
android.util.Log.e("ChatDetailVM", "getLocalDateString error: ${e.message}", e)
isoDate.split("T").firstOrNull() ?: "1970-01-01"
}
}
fun removePendingAttachment(file: File) {
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
}
fun uploadMedia(file: File) {
if (file.length() > _state.value.maxFileSize) {
_state.update { it.copy(error = "File too large") }
return
}
viewModelScope.launch {
try {
val url = repository.uploadMedia(file)
// After upload, we might want to send a message with this media
// For now, let's just log it or handle as per app requirements
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
fun loadTrendingGifs() {
if (_state.value.trendingGifs.isNotEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.getTrendingGifs(0)
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
}
}
}
fun loadGifCategories() {
if (_state.value.gifCategories.isNotEmpty()) return
viewModelScope.launch {
try {
val categories = repository.getGifCategories()
_state.update { it.copy(gifCategories = categories) }
} catch (e: Exception) {
// Ignore failure
}
}
}
private var searchJob: Job? = null
fun searchGifs(query: String) {
searchJob?.cancel()
if (query.isBlank()) {
_state.update { it.copy(searchedGifs = emptyList()) }
return
}
searchJob = viewModelScope.launch {
delay(500)
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.searchGifs(query, 0)
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false) }
}
}
}
fun sendGif(url: String) {
val chatId = currentChatId ?: return
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
viewModelScope.launch {
try {
val attachment = chats.data.remote.api.AttachmentRequest(
type = "image",
url = url,
fileName = "gif.gif",
fileSize = 0
)
repository.sendMessage(
chatId = chatId,
content = null,
type = "image",
attachments = listOf(attachment),
replyToId = replyToId
)
// Сообщение автоматически появится в UI через Flow из Room
// Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
val selectedGif = allGifs.find { gif ->
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
gifUrl == url
}
selectedGif?.let { gif ->
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
_state.update { it.copy(recentGifs = newList) }
}
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
}

View File

@@ -1,127 +0,0 @@
package chats.presentation.chat_list
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chats.presentation.components.ChatItem
import stories.presentation.StoryViewModel
import stories.presentation.components.StoryThumbnail
import ru.knot.messager.R
@Composable
fun HorizontalDividerComponent(
modifier: Modifier = Modifier,
thickness: androidx.compose.ui.unit.Dp = 1.dp,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
) {
androidx.compose.material3.Divider(
modifier = modifier,
thickness = thickness,
color = color
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatListScreen(
viewModel: ChatListViewModel,
storyViewModel: StoryViewModel,
onChatClick: (String) -> Unit,
onStoryClick: (Int) -> Unit
) {
val state by viewModel.state.collectAsState()
val storyState by storyViewModel.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.loadChats()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (state.isLoading && state.chats.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn {
// Ряд историй сверху списка чатов
item {
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
items(storyState.storyGroups.size) { index ->
val group = storyState.storyGroups[index]
StoryThumbnail(
username = group.username,
avatarUrl = group.avatar,
hasUnseen = true, // В идеале проверяем по статусам
onClick = { onStoryClick(index) }
)
}
}
HorizontalDividerComponent(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
if (state.error != null && state.chats.isEmpty()) {
item {
Text(
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
} else if (state.chats.isEmpty() && !state.isLoading) {
item {
Box(modifier = Modifier.fillParentMaxSize()) {
Text(
text = stringResource(R.string.no_chats_found),
modifier = Modifier.align(Alignment.Center)
)
}
}
} else {
items(state.chats) { chat ->
ChatItem(chat = chat, onClick = onChatClick)
HorizontalDividerComponent(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
}

View File

@@ -1,183 +0,0 @@
package chats.presentation.chat_list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.domain.model.Chat
import chats.domain.repository.ChatRepository
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent
import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import chats.data.repository.toDomain
data class ChatListState(
val chats: List<Chat> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isStoriesEnabled: Boolean = true
)
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val repository: ChatRepository,
private val authRepository: auth.domain.repository.AuthRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : ViewModel() {
private val _state = MutableStateFlow(ChatListState())
val state: StateFlow<ChatListState> = _state.asStateFlow()
init {
updatePushToken()
val isStoriesEnabled = try {
serverConfig.getServerConfig().features.stories
} catch (e: Exception) {
true
}
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
// Подключаемся к SignalR только если есть токен И введён URL сервера
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
}
// Single Source of Truth - подписываемся на Flow из Room
observeChatsFromLocal()
// Запускаем периодическую синхронизацию
viewModelScope.launch {
repository.schedulePeriodicSync()
}
observeSignalREvents()
}
/**
* Подписка на локальные чаты из Room (Single Source of Truth)
*/
private fun observeChatsFromLocal() {
repository.getChatsFlow()
.onEach { chats ->
_state.update { currentState ->
currentState.copy(
chats = sortChats(chats),
isLoading = false
)
}
}
.catch { e ->
android.util.Log.e("ChatListVM", "Error observing chats", e)
_state.update { it.copy(isLoading = false, error = e.message) }
}
.launchIn(viewModelScope)
}
/**
* Принудительная синхронизация чатов с сервером
*/
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
repository.syncChats()
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun updatePushToken() {
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
try {
authRepository.updatePushToken(token)
} catch (e: Exception) {
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}")
}
}
}
}
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
private fun sortChats(chats: List<Chat>): List<Chat> {
return chats.sortedWith(compareByDescending<Chat> {
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
}.thenByDescending { it.lastMessage?.createdAt })
}
private fun observeSignalREvents() {
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
updateChatsWithNewMessage(event)
}
is ChatEvent.NewChat -> {
val currentUserId = getCurrentUserId()
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
}
is ChatEvent.MessagesRead -> {
if (event.userId == getCurrentUserId()) {
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.chatId) {
chat.copy(unreadCount = 0)
} else chat
}
currentState.copy(chats = sortChats(updatedChats))
}
}
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id.equals(event.message.chatId, ignoreCase = true)) {
val isMyMessage = event.message.senderId == currentUserId
val isAlreadySeen = chat.lastMessage?.id == event.message.id
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
val newCount = if (isMyMessage || isAlreadySeen) {
chat.unreadCount
} else {
chat.unreadCount + 1
}
if (!isAlreadySeen) {
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
}
chat.copy(
lastMessage = lastMsgDomain,
unreadCount = newCount
)
} else chat
}
currentState.copy(chats = sortChats(updatedChats))
}
}
}

View File

@@ -1,124 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chats.domain.model.Chat
import androidx.compose.foundation.shape.RoundedCornerShape
import core.presentation.components.AppAvatar
import chats.domain.model.MediaType
import androidx.compose.runtime.remember
@Composable
fun ChatItem(
chat: Chat,
onClick: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(chat.id) }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = chat.avatar,
name = chat.name,
size = 50.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = chat.name,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
chat.lastMessage?.let {
val formattedTime = remember(it.createdAt) {
try {
it.createdAt.substringAfter('T').take(5)
} catch (e: Exception) {
""
}
}
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val previewText = remember(chat.lastMessage) {
val msg = chat.lastMessage
if (msg == null) return@remember "No messages yet"
if (!msg.content.isNullOrBlank()) {
msg.content
} else if (msg.mediaType == MediaType.AUDIO) {
"Голосовое сообщение"
} else if (msg.media.isNotEmpty()) {
when (msg.mediaType) {
MediaType.IMAGE -> "Фото"
MediaType.VIDEO -> "Видео"
MediaType.FILE -> "Файл"
else -> "Медиа"
}
} else {
"Сообщение"
}
}
Text(
text = previewText,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (chat.unreadCount > 0) {
Box(
modifier = Modifier
.padding(start = 8.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = chat.unreadCount.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}

View File

@@ -1,39 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun EmojiPicker(
onEmojiSelected: (String) -> Unit
) {
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "") // Базовый набор
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(8.dp)
) {
items(emojis) { emoji ->
Box(
modifier = Modifier
.size(48.dp)
.clickable { onEmojiSelected(emoji) },
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 24.sp)
}
}
}
}

View File

@@ -1,84 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import chats.data.remote.api.KlipyGifDto
@Composable
fun KlipyPicker(
gifs: List<KlipyGifDto>,
isLoading: Boolean,
onGifSelected: (String) -> Unit,
onSearch: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
val context = LocalContext.current
val imageLoader = coil.ImageLoader.Builder(context)
.components {
if (android.os.Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
OutlinedTextField(
value = query,
onValueChange = {
query = it
onSearch(it)
},
modifier = Modifier.fillMaxWidth().padding(8.dp),
placeholder = { Text("Search GIFs...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
)
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(4.dp)
) {
items(gifs) { gif ->
val url = gif.images?.fixed_height?.url ?: ""
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.build(),
imageLoader = imageLoader,
contentDescription = gif.title,
modifier = Modifier
.padding(4.dp)
.aspectRatio(1.5f)
.clickable { onGifSelected(url) },
contentScale = ContentScale.Crop
)
}
}
}
}
}

View File

@@ -1,181 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.utils.LinkMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
@Composable
fun LinkPreview(
url: String,
modifier: Modifier = Modifier,
contentColor: Color = Color.White
) {
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(linkMetadataCache[url]) }
var loading by remember(url) { mutableStateOf(metadata == null) }
val context = LocalContext.current
LaunchedEffect(url) {
if (metadata != null) {
loading = false
return@LaunchedEffect
}
loading = true
try {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}")
.header("User-Agent", "KnotMessenger/1.0 (Android)")
.build()
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (body != null) {
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
if (json.has("status") && json.get("status").asString == "success") {
val data = json.getAsJsonObject("data")
val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString
val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString
val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString
if (title != null || description != null || imageUrl != null) {
metadata = LinkMetadata(
url = url,
title = title,
description = description,
imageUrl = imageUrl
)
metadata?.let { linkMetadataCache[url] = it }
}
Unit
}
Unit
}
Unit
} else {
android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}")
}
}
Unit
}
} catch (e: Exception) {
android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e)
} finally {
loading = false
}
}
if (loading) {
Text(
text = "Загрузка предпросмотра...",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.5f),
modifier = modifier.padding(vertical = 4.dp),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
return
}
val currentMetadata = metadata ?: LinkMetadata(url = url)
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.2f))
.clickable {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {}
}
.border(
width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp,
color = Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp)
)
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(3.dp)
.background(Color(0xFF3096E5))
)
Column(modifier = Modifier.padding(10.dp)) {
val domain = remember(url) {
try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" }
}
Text(
text = domain,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3096E5),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 2.dp)
)
Text(
text = currentMetadata.title ?: url,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
currentMetadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
}
}
currentMetadata.imageUrl?.let {
AsyncImage(
model = it,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
contentScale = ContentScale.Crop
)
}
}
}

View File

@@ -1,416 +0,0 @@
package chats.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import chats.data.remote.api.KlipyGifDto
import core.utils.CoilUtils
import kotlinx.coroutines.launch
enum class MediaTab { EMOJI, GIF }
@Composable
fun MediaPicker(
trendingGifs: List<KlipyGifDto>,
searchedGifs: List<KlipyGifDto>,
recentGifs: List<KlipyGifDto> = emptyList(),
gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
isGifsLoading: Boolean,
error: String?,
onEmojiSelected: (String) -> Unit,
onGifSelected: (String) -> Unit,
onGifSearch: (String) -> Unit,
modifier: Modifier = Modifier
) {
var activeTab by remember { mutableStateOf(MediaTab.EMOJI) }
// Вычисляем индексы для прыжков (учитываем заголовки)
val emojiCategories = remember {
listOf(
EmojiCategory("Недавние", Icons.Rounded.Schedule, listOf("👍", "❤️", "😂", "😍", "🙏", "🔥", "😭", "😊")),
EmojiCategory("Смайлы", Icons.Rounded.EmojiEmotions, listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇")),
EmojiCategory("Животные", Icons.Rounded.Pets, listOf("🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷")),
EmojiCategory("Еда", Icons.Rounded.Fastfood, listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭")),
EmojiCategory("Спорт", Icons.Rounded.SportsFootball, listOf("", "🏀", "🏈", "", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🥅", "🏒")),
EmojiCategory("Транспорт", Icons.Rounded.DirectionsCar, listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛")),
EmojiCategory("Объекты", Icons.Rounded.Lightbulb, listOf("💡", "🔦", "🕯️", "🪔", "📔", "📕", "📖", "💚", "💙", "💜", "🤎", "🖤")),
EmojiCategory("Музыка", Icons.Rounded.MusicNote, listOf("🎵", "🎶", "🎼", "🎹", "🎸", "🎻", "🎷", "🎺", "🥁", "🎤", "🎧", "📻")),
EmojiCategory("Флаги", Icons.Rounded.Flag, listOf("🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇱", "🇩🇿", "🇦🇸", "🇦🇩"))
)
}
val categoryIndices = remember(emojiCategories) {
var currentIdx = 0
emojiCategories.map { cat ->
val startIdx = currentIdx
currentIdx += 1 + cat.emojis.size
cat.icon to startIdx
}
}
Column(
modifier = modifier
.fillMaxWidth()
.height(450.dp)
.background(Color(0xFF1C1C1C), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
) {
// Табы (Эмодзи / GIF)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.background(Color(0xFF2C2C2C), RoundedCornerShape(12.dp))
.padding(2.dp)
) {
MediaTabItem(
title = "ЭМОДЗИ",
isSelected = activeTab == MediaTab.EMOJI,
onClick = { activeTab = MediaTab.EMOJI },
modifier = Modifier.weight(1f)
)
MediaTabItem(
title = "GIF",
isSelected = activeTab == MediaTab.GIF,
onClick = { activeTab = MediaTab.GIF },
modifier = Modifier.weight(1f)
)
}
Crossfade(targetState = activeTab, label = "media_tab") { tab ->
when (tab) {
MediaTab.EMOJI -> EmojiContent(
emojiCategories = emojiCategories,
categoryIndices = categoryIndices,
onEmojiSelected = onEmojiSelected
)
MediaTab.GIF -> GifContent(
trendingGifs = trendingGifs,
searchedGifs = searchedGifs,
recentGifs = recentGifs,
gifCategories = gifCategories,
isGifsLoading = isGifsLoading,
error = error,
onGifSelected = onGifSelected,
onGifSearch = onGifSearch
)
}
}
}
}
@Composable
private fun MediaTabItem(
title: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(if (isSelected) Color(0xFF3C3C3C) else Color.Transparent)
.clickable { onClick() }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = title,
color = if (isSelected) Color.White else Color.Gray,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun EmojiContent(
emojiCategories: List<EmojiCategory>,
categoryIndices: List<Pair<ImageVector, Int>>,
onEmojiSelected: (String) -> Unit
) {
val scrollState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
// Категории
LazyRow(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categoryIndices) { (icon, index) ->
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier
.size(24.dp)
.clickable {
scope.launch {
scrollState.animateScrollToItem(index)
}
}
)
}
}
// Поиск
TextField(
value = "",
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clip(RoundedCornerShape(8.dp)),
placeholder = { Text("Поиск", color = Color.Gray) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2C2C2C),
unfocusedContainerColor = Color(0xFF2C2C2C),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
LazyVerticalGrid(
columns = GridCells.Fixed(8),
state = scrollState,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
emojiCategories.forEach { category ->
// Заголовок категории
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(8) }) {
Text(
text = category.name,
color = Color.Gray,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(8.dp)
)
}
items(category.emojis, key = { emoji -> emoji }) { emoji ->
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable { onEmojiSelected(emoji) },
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 28.sp)
}
}
}
}
}
}
data class EmojiCategory(val name: String, val icon: ImageVector, val emojis: List<String>)
@Composable
private fun GifContent(
trendingGifs: List<KlipyGifDto>,
searchedGifs: List<KlipyGifDto>,
recentGifs: List<KlipyGifDto>,
gifCategories: List<chats.data.remote.api.GifCategoryDto>,
isGifsLoading: Boolean,
error: String?,
onGifSelected: (String) -> Unit,
onGifSearch: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
var subTab by remember { mutableStateOf("trending") }
val context = LocalContext.current
val imageLoader = CoilUtils.getGifImageLoader(context)
Column(modifier = Modifier.fillMaxSize()) {
TextField(
value = query,
onValueChange = {
query = it
if (it.isNotEmpty()) subTab = "search"
else if (subTab == "search") subTab = "trending"
onGifSearch(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp)),
placeholder = { Text("Поиск GIF...", color = Color.Gray) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2C2C2C),
unfocusedContainerColor = Color(0xFF2C2C2C),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
// Под-вкладки GIF (Recent, Trending, Categories)
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Rounded.Schedule,
contentDescription = null,
tint = if (subTab == "recent") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "recent"; query = "" }
)
Icon(
Icons.Rounded.TrendingUp,
contentDescription = null,
tint = if (subTab == "trending") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "trending"; query = "" }
)
Icon(
Icons.Rounded.GridView,
contentDescription = null,
tint = if (subTab == "categories") Color(0xFF3B82F6) else Color.Gray,
modifier = Modifier.size(20.dp).clickable { subTab = "categories"; query = "" }
)
}
if (subTab == "categories") {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp)
) {
items(gifCategories) { category ->
Box(
modifier = Modifier
.padding(4.dp)
.aspectRatio(1.77f)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2C2C2C))
.clickable {
query = category.query
subTab = "search"
onGifSearch(category.query)
}
) {
AsyncImage(
model = category.preview_url,
contentDescription = category.category,
modifier = Modifier.fillMaxSize().alpha(0.6f),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = category.category.uppercase(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Black,
letterSpacing = 2.sp
)
}
}
}
}
} else {
val displayGifs = when (subTab) {
"recent" -> recentGifs
"search" -> searchedGifs
else -> trendingGifs
}
if (isGifsLoading && displayGifs.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color(0xFF3B82F6))
}
} else if (!isGifsLoading && displayGifs.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(error ?: "Ничего не найдено", color = Color.Gray)
if (error != null) {
Button(onClick = { onGifSearch(query) }) {
Text("Повторить")
}
}
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f).padding(4.dp),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
val url = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
val preview = gif.files?.get("sd")?.get("webp")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("webp")?.url
?: gif.media_formats?.get("tinygif")?.url
?: gif.images?.fixed_height_small?.url
?: url
if (url != null) {
AsyncImage(
model = preview ?: url,
imageLoader = imageLoader,
contentDescription = gif.title,
modifier = Modifier
.padding(2.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF2C2C2C))
.clickable { onGifSelected(url) },
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.padding(2.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF3C3C3C)),
contentAlignment = Alignment.Center
) {
Text(gif.title ?: "GIF", color = Color.Gray, fontSize = 10.sp)
}
}
}
}
}
}
}
}

View File

@@ -1,898 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.painterResource
import ru.knot.messager.R
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.Media
import chats.domain.model.MessageStatus
import coil.compose.AsyncImage
import coil.request.ImageRequest
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
import core.presentation.components.AppAvatar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import chats.presentation.components.LinkPreview
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import core.presentation.components.AppMediaLightbox
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: Message,
isCurrentUser: Boolean,
onReactionClick: (String) -> Unit = {},
autoPlay: Boolean = false,
initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {},
onReply: (Message) -> Unit = {},
onReplyClick: (Message) -> Unit = {},
onSelect: (String) -> Unit = {},
onForward: (Message) -> Unit = {},
onPin: (Message) -> Unit = {},
onEdit: (Message) -> Unit = {},
onDelete: (Message, Boolean) -> Unit = { _, _ -> },
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onMediaClick: (chats.domain.model.Media) -> Unit = {}
) {
val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) }
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
val contentColor = Color.White
var showContextMenu by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val backgroundColor = when {
isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f)
showContextMenu -> Color(0xFF007AFF) // Even brighter highlight
isCurrentUser -> Color(0xFF3096E5)
else -> Color(0xFF212121)
}
val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else {
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
Row(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) { // Clicks anywhere in the Row row now work
detectTapGestures(
onTap = {
if (isSelectionMode) {
onSelect(message.id)
} else {
showContextMenu = !showContextMenu
}
},
onLongPress = {
if (!isSelectionMode) {
onSelect(message.id)
}
}
)
}
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
var bubbleHeight by remember { mutableIntStateOf(0) }
var isNearBottom by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.onGloballyPositioned { coords ->
bubbleHeight = coords.size.height
val screenHeight = coords.parentLayoutCoordinates?.size?.height ?: 2000
val yInWindow = coords.localToWindow(Offset.Zero).y
// If bottom of message is in the last 40% of screen, open menu UP
isNearBottom = yInWindow > screenHeight * 0.6f
},
contentAlignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
) {
// Actual bubble
Column(
modifier = Modifier
.clip(shape)
.background(backgroundColor)
.padding(8.dp)
) {
if (showContextMenu) {
val density = androidx.compose.ui.platform.LocalDensity.current
val menuWidth = 220.dp
val menuWidthPx = with(density) { menuWidth.toPx() }
Popup(
alignment = if (isNearBottom) {
if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd
} else {
if (isCurrentUser) Alignment.TopStart else Alignment.TopEnd
},
offset = if (isCurrentUser) {
// Message is on RIGHT, show menu to its LEFT
IntOffset(-menuWidthPx.toInt() - 20, 0)
} else {
// Message is on LEFT, show menu to its RIGHT
IntOffset(menuWidthPx.toInt() + 20, 0)
},
onDismissRequest = { showContextMenu = false },
properties = androidx.compose.ui.window.PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
MessageContextMenu(
isMyMessage = isCurrentUser,
isPinned = message.isPinned,
onReactionSelected = {
onReactionClick(it)
showContextMenu = false
},
onAction = { action ->
showContextMenu = false
when(action) {
"reply" -> onReply(message)
"select" -> onSelect(message.id)
"forward" -> onForward(message)
"pin" -> onPin(message)
"edit" -> onEdit(message)
"copy" -> {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("message", message.content)
clipboard.setPrimaryClip(clip)
}
"delete" -> {
showDeleteDialog = true
}
}
}
)
}
}
// Forwarded Info
if (message.isForwarded) {
Text(
text = "Forwarded from ${message.forwardedFromName ?: "Unknown"}",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.7f),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Reply Info
message.replyTo?.let { reply ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f))
.height(IntrinsicSize.Min)
.clickable { onReplyClick(reply) }
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(2.dp)
.background(if (isCurrentUser) Color.White else Color(0xFF3096E5))
)
val context = androidx.compose.ui.platform.LocalContext.current
val isReplyImage = reply.mediaType == MediaType.IMAGE || reply.mediaType == MediaType.VIDEO || reply.mediaType == MediaType.GIF
if (isReplyImage && reply.media.isNotEmpty()) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(reply.media.first().url)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
.width(40.dp)
.clip(RoundedCornerShape(0.dp, 4.dp, 4.dp, 0.dp)),
contentScale = ContentScale.Crop
)
}
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) {
val accentColor = if (isCurrentUser) Color.White else Color(0xFF3096E5)
Text(
text = reply.senderName,
style = MaterialTheme.typography.labelMedium,
color = accentColor,
maxLines = 1,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
val photoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_photo)
val videoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_video)
val voiceText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.voice_message)
val audioText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_audio)
val fileText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_file)
val gifText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_gif)
val mediaText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.media)
val replyText = remember(reply) {
if (!reply.content.isNullOrEmpty()) {
reply.content
} else {
when (reply.mediaType) {
MediaType.IMAGE -> photoText
MediaType.VIDEO -> videoText
MediaType.AUDIO -> if (reply.media.any { it.type == "voice" }) voiceText else audioText
MediaType.FILE -> fileText
MediaType.GIF -> gifText
else -> mediaText
}
}
}
Text(
text = replyText,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
// GIF Content (Klipy)
if (message.mediaType == MediaType.GIF) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { /* Handle click */ }
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(message.content)
.crossfade(true)
.build(),
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Loading state for temp media messages
if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = contentColor.copy(alpha = 0.5f)
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Media Content (Attachments)
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
val mediaCount = message.media.size
if (mediaCount == 1) {
val mediaItem = message.media[0]
when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onMediaClick(mediaItem) }
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(mediaItem.url)
.crossfade(true)
.memoryCacheKey(mediaItem.url)
.build(),
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.clip(RoundedCornerShape(8.dp))
) {
AppVideoPlayer(
url = mediaItem.url,
modifier = Modifier.fillMaxSize(),
useController = false,
isMuted = true,
autoPlay = true
)
// Прозрачный слой поверх видео для клика
Box(modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.clickable { onMediaClick(mediaItem) }
)
Icon(
imageVector = Icons.Default.VolumeOff,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(20.dp)
)
}
}
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
AppAudioPlayer(
url = mediaItem.url,
name = mediaItem.filename,
size = mediaItem.size,
isVoiceMessage = isVoiceMessage,
initialPlaybackSpeed = initialPlaybackSpeed,
autoPlay = autoPlay,
onFinished = onVoiceFinished,
contentColor = contentColor
)
}
else -> {
FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
}
}
} else {
// Multi-media grid
PhotoGrid(
mediaList = message.media,
isCurrentUser = isCurrentUser,
onMediaClick = { index -> onMediaClick(message.media[index]) },
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Column {
val annotatedString = remember(message.content) {
val text = message.content ?: ""
val links = core.utils.LinkParser.findLinks(text)
androidx.compose.ui.text.buildAnnotatedString {
append(text)
links.forEach { link ->
val startIndex = text.indexOf(link)
if (startIndex >= 0) {
val endIndex = startIndex + link.length
addStyle(
style = androidx.compose.ui.text.SpanStyle(
color = Color(0xFF3096E5),
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = "URL",
annotation = link,
start = startIndex,
end = endIndex
)
}
}
}
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.foundation.text.ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
context.startActivity(intent)
} catch (e: Exception) {}
}
}
)
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
if (links.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LinkPreview(
url = links[0],
contentColor = contentColor
)
}
}
}
// Time and Status
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick,
modifier = Modifier.padding(end = 4.dp)
)
}
val formattedTime = remember(message.createdAt) {
try {
val instant = Instant.parse(message.createdAt)
val zonedDateTime = instant.atZone(ZoneId.systemDefault())
zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (e: Exception) {
message.createdAt.substringAfter('T').take(5)
}
}
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f),
fontSize = 11.sp
)
if (message.isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(12.dp).padding(start = 2.dp).graphicsLayer { rotationZ = 45f },
tint = contentColor.copy(alpha = 0.6f)
)
}
if (isCurrentUser) {
Spacer(modifier = Modifier.width(2.dp))
// Отображение статуса отправки сообщения
when (message.status) {
MessageStatus.PENDING -> {
// Часы - ожидает отправки
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pending",
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
}
MessageStatus.SENDING -> {
// Круговой индикатор загрузки
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
strokeWidth = 1.5.dp,
color = contentColor.copy(alpha = 0.6f)
)
}
MessageStatus.FAILED -> {
// Красный крестик - ошибка отправки
Icon(
imageVector = Icons.Default.Error,
contentDescription = "Failed",
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF5252)
)
}
else -> {
// Одна галочка для SENT, две для DELIVERED/READ
Icon(
imageVector = if (message.status == MessageStatus.READ) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = if (message.status == MessageStatus.READ) Color(0xFF4CAF50) else contentColor.copy(alpha = 0.6f)
)
}
}
}
}
} // End of bubble Column
if (isSelected) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.align(Alignment.Center)
.size(32.dp)
.background(Color(0xFF3096E5), CircleShape)
.padding(2.dp)
)
}
} // End of Box
} // End of Row
lightboxMediaIndex?.let { index ->
AppMediaLightbox(
mediaList = message.media,
initialIndex = index,
onClose = { lightboxMediaIndex = null }
)
}
if (showDeleteDialog) {
var deleteForEveryone by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Удалить сообщение?") },
text = {
Column {
Text("Вы уверены, что хотите удалить это сообщение?")
if (isCurrentUser) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp).clickable { deleteForEveryone = !deleteForEveryone }
) {
Checkbox(
checked = deleteForEveryone,
onCheckedChange = { deleteForEveryone = it }
)
Text("Удалить у всех")
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onDelete(message, deleteForEveryone)
showDeleteDialog = false
},
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935))
) {
Text("Удалить")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Отмена")
}
}
)
}
}
@Composable
fun MessageReactions(
reactions: Map<String, Int>,
onReactionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { (emoji, count) ->
Box(
modifier = Modifier
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { onReactionClick(emoji) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = emoji, fontSize = 12.sp)
if (count > 1) {
Spacer(modifier = Modifier.width(2.dp))
Text(text = count.toString(), fontSize = 10.sp, color = Color.White)
}
}
}
}
}
}
@Composable
fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Description, contentDescription = null, tint = contentColor)
}
Column(
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
) {
val fileName = media.filename ?: "File"
Text(
text = fileName,
style = MaterialTheme.typography.bodySmall,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val sizeStr = media.size?.let {
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
} ?: "File"
Text(
text = sizeStr,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f)
)
}
Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp))
}
}
@Composable
fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
if (mediaList.isEmpty()) return
val columns = when {
mediaList.size == 1 -> 1
mediaList.size <= 4 -> 2
else -> 3
}
Column(modifier = modifier) {
val rows = (mediaList.size + columns - 1) / columns
for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
for (j in 0 until columns) {
val index = i * columns + j
if (index < mediaList.size) {
GridItem(
media = mediaList[index],
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(index) }
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
fun GridItem(media: Media, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isImage = media.type.startsWith("image")
val isVideo = media.type.startsWith("video")
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
if (isImage || isVideo) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(media.url)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isVideo) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.Center).size(32.dp)
)
}
} else {
Column(
modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(32.dp)
)
Text(
text = media.filename ?: "File",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Text(
text = media.url.substringAfterLast(".").uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.5f),
fontSize = 8.sp
)
}
}
}
}
@Composable
fun MessageContextMenu(
isMyMessage: Boolean,
isPinned: Boolean,
onReactionSelected: (String) -> Unit,
onAction: (String) -> Unit
) {
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
androidx.compose.animation.AnimatedVisibility(
visible = isVisible,
enter = androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(200)) +
androidx.compose.animation.scaleIn(
initialScale = 0.5f,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = 0.6f,
stiffness = 400f
),
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(
if (isVisible) 0.5f else 0.5f,
0f
)
),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut()
) {
androidx.compose.material3.Surface(
modifier = Modifier
.width(280.dp) // Even wider for all reactions
.clip(RoundedCornerShape(20.dp)),
color = Color(0xFF1E1E1E),
shadowElevation = 15.dp,
border = androidx.compose.foundation.BorderStroke(0.5.dp, Color.White.copy(alpha = 0.15f))
) {
Column {
// Extended Reactions scrollable bar
androidx.compose.foundation.lazy.LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(horizontal = 12.dp)
) {
val telegramReactions = listOf(
"👍", "❤️", "😂", "🔥", "💯", "👏", "🤩", "🤔", "🤯", "😱", "🤬",
"👎", "💩", "🤡", "🥳", "😇", "🌚", "🙏", "👌", "🕊️", "🐳",
"🌭", "🦄", "🍌", "💊", "🍓", "🍾", "💋", "🖕", "👀", "👻",
"🤝", "🤣", "", "", "🎈", "🎉", "🧊", "🆒", "🤨", "😐",
"🤷‍♂️", "💅", "🏆", "👾", "🎯", "🎲", "🍷", "🤮", "🥱", "😴",
"🤐", "🥴"
)
items(telegramReactions) { emoji ->
Text(
text = emoji,
fontSize = 28.sp,
modifier = Modifier
.clip(CircleShape)
.clickable { onReactionSelected(emoji) }
.padding(4.dp)
)
}
}
Divider(color = Color.White.copy(alpha = 0.1f))
ContextMenuItem(Icons.Default.Reply, "Ответить", onClick = { onAction("reply") })
ContextMenuItem(Icons.Default.CheckCircle, "Выбрать", onClick = { onAction("select") })
ContextMenuItem(Icons.Default.Forward, "Переслать", onClick = { onAction("forward") })
ContextMenuItem(
icon = Icons.Default.PushPin,
text = if (isPinned) "Открепить" else "Закрепить",
onClick = { onAction("pin") }
)
ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") })
if (isMyMessage) {
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
}
Divider(color = Color.White.copy(alpha = 0.1f))
ContextMenuItem(
Icons.Default.Delete,
"Удалить",
textColor = Color(0xFFE53935),
onClick = { onAction("delete") }
)
}
}
}
}
@Composable
fun ContextMenuItem(
icon: ImageVector,
text: String,
textColor: Color = Color.White,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = textColor.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = text,
color = textColor,
style = MaterialTheme.typography.bodyMedium
)
}
}

View File

@@ -1,61 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ReactionPicker(
onReactionSelected: (String) -> Unit
) {
val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
Row(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface, CircleShape)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
reactions.forEach { reaction ->
Text(
text = reaction,
modifier = Modifier
.size(32.dp)
.clickable { onReactionSelected(reaction) },
fontSize = 20.sp
)
}
}
}
@Composable
fun MessageReactions(
reactions: Map<String, Int>, // Эмодзи -> Количество
onReactionClick: (String) -> Unit
) {
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { (emoji, count) ->
Box(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.clickable { onReactionClick(emoji) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(text = "$emoji $count", fontSize = 12.sp)
}
}
}
}

View File

@@ -1,87 +0,0 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Reply
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun SwipeableMessageItem(
onReply: () -> Unit,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val replyThreshold = with(density) { 60.dp.toPx() }
val maxDrag = with(density) { 90.dp.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var isTriggered by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
// We only allow swiping to the left (negative delta)
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
offsetX = newOffset
isTriggered = newOffset <= -replyThreshold
},
onDragStopped = {
if (offsetX <= -replyThreshold) {
onReply()
}
offsetX = 0f
isTriggered = false
}
)
) {
// Reply Icon (Underneath)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.size(36.dp)
.alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
.clip(CircleShape)
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Reply,
contentDescription = "Reply",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
// Message Content
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.fillMaxWidth()
) {
content()
}
}
}

View File

@@ -1,62 +0,0 @@
package contacts.data.remote.api
import retrofit2.http.*
interface ContactApi {
@GET("contacts")
suspend fun getContacts(): List<ContactDto>
@POST("contacts/request")
suspend fun addContact(@Body request: AddContactRequest): Any
@DELETE("contacts/{userId}")
suspend fun removeContact(@Path("userId") userId: String)
@GET("profiles/search")
suspend fun searchUsers(@Query("q") query: String): List<ContactDto>
@GET("contacts/requests")
suspend fun getFriendRequests(): List<FriendRequestDto>
@POST("contacts/{friendshipId}/accept")
suspend fun acceptRequest(@Path("friendshipId") friendshipId: String): Any
@POST("contacts/{friendshipId}/decline")
suspend fun declineRequest(@Path("friendshipId") friendshipId: String): Any
}
data class FriendRequestDto(
val id: String,
val user: ContactDto,
val createdAt: String,
val isOutgoing: Boolean
)
data class ContactDto(
@com.google.gson.annotations.SerializedName("id")
val id: String,
@com.google.gson.annotations.SerializedName("userName")
val userName: String?,
@com.google.gson.annotations.SerializedName("username")
val username: String?,
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
@com.google.gson.annotations.SerializedName("avatarUrl")
val avatarUrl: String?,
@com.google.gson.annotations.SerializedName("avatar")
val avatar: String?,
@com.google.gson.annotations.SerializedName("isOnline")
val isOnline: Boolean,
@com.google.gson.annotations.SerializedName("lastSeen")
val lastSeen: String?,
@com.google.gson.annotations.SerializedName("friendshipId")
val friendshipId: String? = null
) {
val effectiveUsername: String get() = username ?: userName ?: "unknown"
val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar
}
data class AddContactRequest(
@com.google.gson.annotations.SerializedName("contactId")
val contactId: String
)

View File

@@ -1,74 +0,0 @@
package contacts.data.repository
import contacts.data.remote.api.ContactApi
import contacts.data.remote.api.ContactDto
import contacts.domain.repository.ContactRepository
import javax.inject.Inject
class ContactRepositoryImpl @Inject constructor(
private val api: ContactApi
) : ContactRepository {
override suspend fun getContacts(): Result<List<ContactDto>> {
return try {
Result.success(api.getContacts())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addContact(userId: String): Result<ContactDto> {
return try {
val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId))
// Бэкенд возвращает статус или пустой ответ для запроса,
// так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или
// по-хорошему надо обновить доменную модель, но для начала вернем заглушку.
Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun removeContact(userId: String): Result<Unit> {
return try {
api.removeContact(userId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
return try {
Result.success(api.searchUsers(query))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
return try {
Result.success(api.getFriendRequests())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
return try {
api.acceptRequest(friendshipId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
return try {
api.declineRequest(friendshipId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -1,30 +0,0 @@
package contacts.di
import contacts.data.remote.api.ContactApi
import contacts.data.repository.ContactRepositoryImpl
import contacts.domain.repository.ContactRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ContactModule {
@Provides
@Singleton
fun provideContactApi(retrofit: Retrofit): ContactApi {
return retrofit.create(ContactApi::class.java)
}
@Provides
@Singleton
fun provideContactRepository(
api: ContactApi
): ContactRepository {
return ContactRepositoryImpl(api)
}
}

View File

@@ -1,13 +0,0 @@
package contacts.domain.repository
import contacts.data.remote.api.ContactDto
interface ContactRepository {
suspend fun getContacts(): Result<List<ContactDto>>
suspend fun addContact(userId: String): Result<ContactDto>
suspend fun removeContact(userId: String): Result<Unit>
suspend fun searchUsers(query: String): Result<List<ContactDto>>
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
suspend fun acceptRequest(friendshipId: String): Result<Unit>
suspend fun declineRequest(friendshipId: String): Result<Unit>
}

View File

@@ -1,253 +0,0 @@
package contacts.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import contacts.data.remote.api.ContactDto
import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactListScreen(
contacts: List<contacts.data.remote.api.ContactDto>,
requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
isLoading: Boolean = false,
onContactClick: (String) -> Unit,
onSearchChange: (String) -> Unit,
onAddContact: (String) -> Unit,
onStartChat: (String) -> Unit,
onAcceptRequest: (String) -> Unit,
onDeclineRequest: (String) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf(
stringResource(R.string.all),
stringResource(R.string.online_tab)
)
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text(stringResource(R.string.contacts_title), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Поисковая строка
OutlinedTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
onSearchChange(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text(stringResource(R.string.search_hint)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
shape = SoftSquareShape
)
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val filteredContacts = if (searchQuery.isNotEmpty()) {
contacts
} else {
contacts.filter {
if (selectedTab == 1) it.isOnline else true
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Contact Requests Section
if (searchQuery.isEmpty() && requests.isNotEmpty()) {
item {
Text(
text = "Заявки в контакты",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(requests) { request ->
ContactRequestItem(
request = request,
onAccept = { onAcceptRequest(request.id) },
onDecline = { onDeclineRequest(request.id) }
)
}
item {
Divider(modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant)
}
}
if (filteredContacts.isEmpty() && requests.isEmpty()) {
item {
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = if (searchQuery.isNotEmpty()) "Пользователи не найдены" else "Список контактов пуст",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
items(filteredContacts) { contact ->
ContactItem(
contact = contact,
onClick = { onContactClick(contact.id) },
onAddClick = { onAddContact(contact.id) },
onChatClick = { onStartChat(contact.id) },
isSearchMode = searchQuery.isNotEmpty()
)
}
}
}
}
}
}
}
@Composable
fun ContactRequestItem(
request: contacts.data.remote.api.FriendRequestDto,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = request.user.effectiveAvatarUrl,
name = request.user.effectiveUsername,
size = 48.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = request.user.displayName ?: request.user.effectiveUsername,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (request.isOutgoing) "Исходящий запрос" else "Входящий запрос",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
if (!request.isOutgoing) {
IconButton(onClick = onAccept) {
Icon(Icons.Default.CheckCircle, contentDescription = "Accept", tint = Color(0xFF4CAF50))
}
}
IconButton(onClick = onDecline) {
Icon(Icons.Default.Cancel, contentDescription = "Decline", tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
fun ContactItem(
contact: contacts.data.remote.api.ContactDto,
onClick: () -> Unit,
onAddClick: () -> Unit,
onChatClick: () -> Unit,
isSearchMode: Boolean
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = contact.effectiveAvatarUrl,
name = contact.effectiveUsername,
size = 56.dp
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.displayName ?: contact.effectiveUsername,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
val statusText = if (contact.isOnline) {
stringResource(R.string.online)
} else {
stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
}
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
if (isSearchMode) {
IconButton(onClick = onAddClick) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = "Add Contact",
tint = MaterialTheme.colorScheme.primary
)
}
}
IconButton(onClick = onChatClick) {
Icon(
imageVector = Icons.Default.Chat,
contentDescription = "Start Chat",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@@ -1,122 +0,0 @@
package contacts.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.domain.repository.ChatRepository
import contacts.domain.repository.ContactRepository
import core.utils.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ContactListState(
val contacts: List<contacts.data.remote.api.ContactDto> = emptyList(),
val requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class ContactListViewModel @Inject constructor(
private val contactRepository: ContactRepository,
private val chatRepository: ChatRepository,
private val navigationManager: NavigationManager
) : ViewModel() {
private val _state = MutableStateFlow(ContactListState())
val state: StateFlow<ContactListState> = _state.asStateFlow()
init {
loadData()
}
fun loadData() {
loadContacts()
loadRequests()
}
fun loadContacts() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
contactRepository.getContacts()
.onSuccess { contacts ->
_state.update { it.copy(contacts = contacts, isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun loadRequests() {
viewModelScope.launch {
contactRepository.getFriendRequests()
.onSuccess { requests ->
_state.update { it.copy(requests = requests) }
}
}
}
fun onSearchChange(query: String) {
if (query.length < 3) {
if (query.isEmpty()) loadData()
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
contactRepository.searchUsers(query)
.onSuccess { users ->
_state.update { it.copy(contacts = users, isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun addContact(userId: String) {
viewModelScope.launch {
contactRepository.addContact(userId)
.onSuccess {
loadData()
}
.onFailure { e ->
_state.update { it.copy(error = e.message) }
}
}
}
fun acceptRequest(requestId: String) {
viewModelScope.launch {
contactRepository.acceptRequest(requestId)
.onSuccess { loadData() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
fun declineRequest(requestId: String) {
viewModelScope.launch {
contactRepository.declineRequest(requestId)
.onSuccess { loadData() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
fun startChat(userId: String) {
viewModelScope.launch {
try {
// В вебе мы ищем существующий чат или создаем новый.
// В мобилке мы для начала можем просто вызвать createPersonalChat.
// Бэкенд обычно возвращает существующий чат, если он уже есть.
val chat = chatRepository.createPersonalChat(userId)
navigationManager.navigateToChat(chat.id)
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
}
}
}
}

View File

@@ -1,27 +0,0 @@
package core.di
import android.content.Context
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideContext(@ApplicationContext context: Context): Context {
return context
}
@Provides
@Singleton
fun provideGson(): Gson {
return Gson()
}
}

View File

@@ -1,53 +0,0 @@
package core.di
import com.google.gson.GsonBuilder
import core.network.AuthInterceptor
import core.network.DynamicBaseUrlInterceptor
import core.network.ServerConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(300, TimeUnit.SECONDS)
.addInterceptor(logging)
.addInterceptor(dynamicBaseUrlInterceptor)
.addInterceptor(authInterceptor)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
val gson = com.google.gson.GsonBuilder()
.serializeNulls()
.create()
return Retrofit.Builder()
.baseUrl("https://api.placeholder.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
}

View File

@@ -1,57 +0,0 @@
package core.domain.model
import com.google.gson.annotations.SerializedName
data class ServerConfigModel(
@SerializedName("stories") val stories: StoriesConfig = StoriesConfig(),
@SerializedName("messages") val messages: MessagesConfig = MessagesConfig(),
@SerializedName("chats") val chats: ChatsConfig = ChatsConfig(),
@SerializedName("webRtc") val webRtc: WebRtcConfig = WebRtcConfig()
) {
// Helper to keep the presentation layer simple
val features: FeaturesConfig
get() = FeaturesConfig(
stories = stories.enabled,
polls = messages.allowPolls,
calls = webRtc.enabled && webRtc.enableVoiceCalls,
groups = true // Static for now as chats config doesn't have a simple toggle
)
val limits: LimitsConfig
get() = LimitsConfig(
maxFileSize = messages.maxFileSize,
maxGroupMembers = chats.maxGroupParticipants
)
}
data class StoriesConfig(
@SerializedName("enabled") val enabled: Boolean = true
)
data class MessagesConfig(
@SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024,
@SerializedName("allowPolls") val allowPolls: Boolean = true
)
data class ChatsConfig(
@SerializedName("maxGroupParticipants") val maxGroupParticipants: Int = 200
)
data class WebRtcConfig(
@SerializedName("enabled") val enabled: Boolean = true,
@SerializedName("enableVoiceCalls") val enableVoiceCalls: Boolean = true,
@SerializedName("enableVideoCalls") val enableVideoCalls: Boolean = true
)
// DTOs to maintain compatibility with existing UI code if possible
data class FeaturesConfig(
val stories: Boolean,
val polls: Boolean,
val calls: Boolean,
val groups: Boolean
)
data class LimitsConfig(
val maxFileSize: Long,
val maxGroupMembers: Int
)

View File

@@ -1,79 +0,0 @@
package core.network
import core.security.TokenManager
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.runBlocking
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager,
private val navigationManager: core.utils.NavigationManager,
private val authRepositoryProvider: javax.inject.Provider<auth.domain.repository.AuthRepository>
) : Interceptor {
@Volatile
private var isRefreshing = false
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenManager.getToken()
val request = chain.request().newBuilder().apply {
token?.let {
addHeader("Authorization", "Bearer $it")
}
}.build()
var response = chain.proceed(request)
if (response.code == 401) {
// Attempt to refresh token synchronously
val refreshedToken = refreshAuthToken()
if (refreshedToken != null) {
// Close the old response before retrying
response.close()
// Retry the original request with the new token
val retryRequest = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $refreshedToken")
.build()
response = chain.proceed(retryRequest)
} else {
// Refresh failed, logout
logoutUser()
}
}
return response
}
private fun refreshAuthToken(): String? {
if (isRefreshing) {
// Already refreshing, wait and return current token
return tokenManager.getToken()
}
return runBlocking {
isRefreshing = true
try {
val result = authRepositoryProvider.get().refreshToken()
if (result.isSuccess) {
result.getOrNull()?.token
} else {
null
}
} finally {
isRefreshing = false
}
}
}
private fun logoutUser() {
runBlocking {
authRepositoryProvider.get().logout()
}
navigationManager.logout()
}
}

View File

@@ -1,46 +0,0 @@
package core.network
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DynamicBaseUrlInterceptor @Inject constructor(
private val serverConfig: ServerConfig
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val newBaseUrlString = serverConfig.getBaseUrl()
val newBaseUrl = newBaseUrlString.toHttpUrlOrNull() ?: return chain.proceed(originalRequest)
val oldUrl = originalRequest.url
// Формируем новый URL.
// Retrofit-пути у нас начинаются БЕЗ слеша (например "auth/login")
// Поэтому мы просто берем путь из настроек и добавляем к нему путь запроса.
val newUrl = newBaseUrl.newBuilder().apply {
val pathSegments = oldUrl.pathSegments
// Добавляем сегменты пути запроса к базовому пути из настроек
pathSegments.forEach { segment ->
if (segment.isNotEmpty()) {
addEncodedPathSegment(segment)
}
}
// Копируем параметры запроса (query params)
for (i in 0 until oldUrl.querySize) {
addEncodedQueryParameter(
oldUrl.queryParameterName(i),
oldUrl.queryParameterValue(i)
)
}
}.build()
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
}
}

View File

@@ -1,50 +0,0 @@
package core.network
import android.content.Context
import com.google.gson.Gson
import core.domain.model.FeaturesConfig
import core.domain.model.ServerConfigModel
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerConfig @Inject constructor(
private val context: Context,
private val gson: Gson
) {
private val prefs = context.getSharedPreferences("server_config_prefs", Context.MODE_PRIVATE)
companion object {
private const val KEY_BASE_URL = "base_url"
private const val KEY_CONFIG_DATA = "server_config_data"
private const val DEFAULT_BASE_URL = "" // Пустой по умолчанию
}
fun getBaseUrl(): String {
return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL
}
fun setBaseUrl(url: String) {
val formattedUrl = if (url.endsWith("/")) url else "$url/"
prefs.edit().putString(KEY_BASE_URL, formattedUrl).apply()
}
fun saveServerConfig(config: ServerConfigModel) {
android.util.Log.d("ServerConfig", "Saving new config: $config")
prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).commit()
}
fun getServerConfig(): ServerConfigModel {
val json = prefs.getString(KEY_CONFIG_DATA, null) ?: return ServerConfigModel()
return try {
gson.fromJson(json, ServerConfigModel::class.java)
} catch (e: Exception) {
ServerConfigModel()
}
}
fun isFeatureEnabled(feature: (FeaturesConfig) -> Boolean): Boolean {
return feature(getServerConfig().features)
}
}

View File

@@ -1,28 +0,0 @@
package core.notifications.data
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@Singleton
class ActiveChatTracker @Inject constructor() {
private val _currentChatId = MutableStateFlow<String?>(null)
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
private val _totalUnreadCount = MutableStateFlow(0)
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount.asStateFlow()
fun setChatId(chatId: String?) {
_currentChatId.value = chatId
}
fun setTotalUnreadCount(count: Int) {
_totalUnreadCount.value = count
}
fun incrementUnreadCount() {
_totalUnreadCount.value += 1
}
}

View File

@@ -1,58 +0,0 @@
package core.notifications.data
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import auth.data.remote.api.AuthApi
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class KnotFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var authApi: AuthApi
@Inject
lateinit var activeChatTracker: ActiveChatTracker
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
override fun onNewToken(token: String) {
super.onNewToken(token)
// Отправляем новый токен на сервер
scope.launch {
try {
authApi.updatePushToken(token)
} catch (e: Exception) {
// Ошибка логируется или игнорируется (сервер может быть недоступен)
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
android.util.Log.d("FCM", "Message received: ${message.data}")
val title = message.notification?.title ?: message.data["title"]
val body = message.notification?.body ?: message.data["body"]
val type = message.data["type"] // "chat", "call", "story"
val chatId = message.data["chatId"]
val messageId = message.data["id"] ?: message.messageId
NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode(), activeChatTracker.totalUnreadCount.value)
}
private fun showNotification(title: String?, body: String?, type: String?) {
NotificationHelper.showNotification(this, title, body, type, totalCount = activeChatTracker.totalUnreadCount.value)
}
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
}

View File

@@ -1,57 +0,0 @@
package core.notifications.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import ru.knot.messager.R
object NotificationHelper {
private const val CHANNEL_ID = "knot_notifications_channel"
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Knot Notifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Chat and system notifications"
}
notificationManager.createNotificationChannel(channel)
}
// Find our launcher activity via package manager to avoid static dependency on MainActivity
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("type", type)
putExtra("chatId", chatId)
} ?: return
val pendingIntent = PendingIntent.getActivity(
context, System.currentTimeMillis().toInt(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
)
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setNumber(totalCount)
.setContentIntent(pendingIntent)
notificationManager.notify(finalNotificationId, notificationBuilder.build())
}
}

View File

@@ -1,310 +0,0 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun AppAudioPlayer(
url: String,
name: String? = null,
size: Long? = null,
isVoiceMessage: Boolean = false,
initialPlaybackSpeed: Float = core.utils.PlaybackManager.globalPlaybackSpeed.value,
autoPlay: Boolean = false,
onFinished: (Float) -> Unit = {},
modifier: Modifier = Modifier,
contentColor: Color = Color.White
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
// Update global speed when changed locally
LaunchedEffect(playbackSpeed) {
core.utils.PlaybackManager.globalPlaybackSpeed.value = playbackSpeed
}
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
playbackParameters = PlaybackParameters(playbackSpeed)
val savedPos = core.utils.PlaybackManager.getPosition(url)
seekTo(savedPos)
prepare()
}
}
// Обработка сигнала авто-запуска
LaunchedEffect(autoPlay) {
if (autoPlay) {
exoPlayer.play()
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) }
var duration by remember { mutableLongStateOf(0L) }
var layoutSize by remember { mutableStateOf(IntSize.Zero) }
// Глобальная синхронизация: если играет кто-то другой, ставим паузу
val globalPlayingUrl by core.utils.PlaybackManager.currentPlayingUrl
LaunchedEffect(globalPlayingUrl) {
if (globalPlayingUrl != url && isPlaying) {
exoPlayer.pause()
}
}
val fileName = remember(url, name) {
name ?: url.substringAfterLast("/")
}
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (playing) {
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
} else if (state == Player.STATE_ENDED) {
exoPlayer.pause() // Явно останавливаем, чтобы не было авто-реплея при seekTo
if (isVoiceMessage) {
exoPlayer.seekTo(0)
currentPosition = 0
core.utils.PlaybackManager.resetPosition(url)
}
onFinished(playbackSpeed)
}
}
}
exoPlayer.addListener(listener)
onDispose {
core.utils.PlaybackManager.savePosition(url, exoPlayer.currentPosition)
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
LaunchedEffect(isPlaying) {
if (isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
core.utils.PlaybackManager.savePosition(url, currentPosition)
delay(200)
}
}
}
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.padding(8.dp)
) {
// Header: Icon and Name
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
imageVector = Icons.Default.VolumeUp,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isVoiceMessage) "Голосовое сообщение" else fileName,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Play/Pause Button
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color.White)
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color(0xFF3390EC),
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Waveform
Box(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.onSizeChanged { layoutSize = it }
.pointerInput(duration) {
detectTapGestures { offset ->
if (duration > 0 && layoutSize.width > 0) {
val pct = offset.x / layoutSize.width.toFloat()
val newPos = (pct * duration).toLong()
exoPlayer.seekTo(newPos)
currentPosition = newPos
}
}
},
contentAlignment = Alignment.CenterStart
) {
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
val barCount = 40
val barSpacing = 2.dp.toPx()
val barWidth = (this.size.width - (barCount - 1) * barSpacing) / barCount
val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f
val barHeights = listOf(
0.3f, 0.5f, 0.2f, 0.7f, 0.4f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f,
0.2f, 0.6f, 0.5f, 0.8f, 0.3f, 0.7f, 0.4f, 0.6f, 0.2f, 0.8f,
0.4f, 0.5f, 0.3f, 0.7f, 0.5f, 0.6f, 0.4f, 0.3f, 0.4f, 0.6f,
0.3f, 0.2f, 0.4f, 0.5f, 0.3f, 0.6f, 0.4f, 0.7f, 0.3f, 0.5f
)
for (i in 0 until barCount) {
val x = i * (barWidth + barSpacing)
val heightPct = barHeights[i % barHeights.size]
val barHeight = this.size.height * heightPct
val isActive = (i.toFloat() / barCount) <= progress
drawRoundRect(
color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f),
topLeft = androidx.compose.ui.geometry.Offset(x, (this.size.height - barHeight) / 2),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx())
)
}
}
}
Spacer(modifier = Modifier.height(2.dp))
// Footer: Time, Size, Download
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = formatDuration(if (isPlaying || currentPosition > 0) currentPosition else duration),
fontSize = 11.sp,
color = contentColor.copy(alpha = 0.7f)
)
if (!isVoiceMessage) {
size?.let {
Spacer(modifier = Modifier.width(12.dp))
val sizeStr = if (it > 1024 * 1024) String.format("%.1f MB", it / (1024.0 * 1024.0)) else "${it / 1024} KB"
Text(
text = sizeStr,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = contentColor.copy(alpha = 0.7f)
)
}
}
}
if (!isVoiceMessage) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).clickable {
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, url, fileName)
}
}
)
}
}
}
// Speed toggle (only for voice messages or if user wants)
if (isVoiceMessage || playbackSpeed > 1.0f) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
onClick = {
playbackSpeed = when (playbackSpeed) {
1.0f -> 1.5f
1.5f -> 2.0f
else -> 1.0f
}
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
},
color = Color.White.copy(alpha = 0.1f),
shape = CircleShape,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
}
}
}
private fun formatDuration(durationMs: Long): String {
if (durationMs <= 0) return "0:00"
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%d:%02d", minutes, seconds)
}

View File

@@ -1,85 +0,0 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import androidx.compose.foundation.shape.RoundedCornerShape
import core.presentation.theme.SoftSquareShape
@Composable
fun AppAvatar(
url: String?,
name: String,
size: Dp = 48.dp,
modifier: Modifier = Modifier
) {
val isSavedMessages = remember(name) {
name.equals("Избранное", ignoreCase = true) || name.equals("Saved Messages", ignoreCase = true)
}
val initials = remember(name) {
if (isSavedMessages) "" else {
val words = name.trim().split("\\s+".toRegex())
if (words.size >= 2) {
(words[0].take(1) + words[1].take(1)).uppercase()
} else if (name.length >= 2) {
name.take(2).uppercase()
} else {
name.take(1).uppercase()
}
}
}
val avatarColor = Color(0xFF4AA6F3) // Premium Telegram Blue (Web)
val cornerRadius = remember(size) {
if (size <= 32.dp) (size.value * 0.25).dp else 12.dp
}
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(cornerRadius))
.background(avatarColor),
contentAlignment = Alignment.Center
) {
if (!url.isNullOrBlank()) {
AsyncImage(
model = url,
contentDescription = name,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop
)
} else if (isSavedMessages) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size((size.value * 0.5).dp)
)
} else {
Text(
text = initials,
color = Color.White,
fontSize = (size.value * 0.4).sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
}
}
}

View File

@@ -1,241 +0,0 @@
package core.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import chats.domain.model.Media
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.launch
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun AppMediaLightbox(
mediaList: List<Media>,
initialIndex: Int = 0,
onClose: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
var canScroll by remember { mutableStateOf(true) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
pageSpacing = 16.dp,
beyondBoundsPageCount = 1,
userScrollEnabled = canScroll
) { page ->
val media = mediaList[page]
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (media.type.startsWith("video")) {
val isCurrentPage = pagerState.currentPage == page
AppVideoPlayer(
url = media.url,
modifier = Modifier.fillMaxSize(),
useController = true,
autoPlay = isCurrentPage // Только если страница активна
)
} else if (media.type.startsWith("image") || media.type.contains("gif")) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
// Reset zoom when page changes
LaunchedEffect(pagerState.currentPage) {
scale = 1f
offset = androidx.compose.ui.geometry.Offset.Zero
canScroll = true
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale > 1f) {
scale = 1f
offset = Offset.Zero
canScroll = true
} else {
scale = 3f
canScroll = false
}
}
)
}
.pointerInput(Unit) {
awaitEachGesture {
do {
val event = awaitPointerEvent()
val zoomActive = scale > 1f || event.changes.size > 1
if (zoomActive) {
// Если мы в режиме зума - поглощаем события
event.changes.forEach { it.consume() }
// Сами вычисляем трансформацию
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
scale = (scale * zoomChange).coerceIn(1f, 5f)
canScroll = scale <= 1f
if (scale > 1f) {
offset += panChange
} else {
offset = Offset.Zero
}
}
// Если zoomActive == false, мы НЕ вызываем consume(),
// и событие уходит наверх в HorizontalPager
} while (event.changes.any { it.pressed })
}
},
contentAlignment = Alignment.Center
) {
AsyncImage(
model = media.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(LocalContext.current),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
),
contentScale = ContentScale.Fit
)
}
}
else {
// Document / File placeholder in Lightbox
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = media.filename ?: "File",
color = Color.White,
style = MaterialTheme.typography.headlineSmall,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.url))
context.startActivity(intent)
} catch (e: Exception) {
// Handle error (no app to open)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("Открыть в приложении")
}
}
}
}
}
// Top Bar
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onClose,
modifier = Modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Close, contentDescription = null, tint = Color.White)
}
if (mediaList.isNotEmpty()) {
Text(
text = "${pagerState.currentPage + 1} / ${mediaList.size}",
color = Color.White,
style = MaterialTheme.typography.titleMedium
)
}
IconButton(
onClick = {
val currentMedia = mediaList[pagerState.currentPage]
scope.launch {
core.utils.DownloadUtils.downloadMedia(context, currentMedia.url, currentMedia.filename)
}
},
modifier = Modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Download, contentDescription = null, tint = Color.White)
}
}
}
}
}

View File

@@ -1,204 +0,0 @@
package core.presentation.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.media3.ui.AspectRatioFrameLayout
import kotlinx.coroutines.delay
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
@OptIn(UnstableApi::class)
@Composable
fun AppVideoPlayer(
url: String,
modifier: Modifier = Modifier,
useController: Boolean = true,
autoPlay: Boolean = true,
isMuted: Boolean = false,
onVideoFinished: () -> Unit = {}
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)
volume = if (isMuted) 0f else 1f
prepare()
playWhenReady = autoPlay
}
}
var isPlaying by remember { mutableStateOf(autoPlay) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
var isControlsVisible by remember { mutableStateOf(true) }
var isDragging by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
} else if (state == Player.STATE_ENDED) {
onVideoFinished()
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
LaunchedEffect(isPlaying, isDragging) {
if (isPlaying && !isDragging) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
delay(500)
}
}
}
Box(modifier = modifier.background(Color.Black)) {
AndroidView(
factory = {
PlayerView(it).apply {
player = exoPlayer
this.useController = false // We use our own UI if needed
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setBackgroundColor(android.graphics.Color.BLACK)
}
},
modifier = Modifier.fillMaxSize().clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = useController // Only clickable if we have controls
) {
isControlsVisible = !isControlsVisible
}
)
AnimatedVisibility(
visible = isControlsVisible && useController,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
) {
// Center Controls
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
IconButton(
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Rounded.Replay10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(44.dp)
)
}
IconButton(
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(duration)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Rounded.Forward10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
}
// Bottom Controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp, start = 16.dp, end = 16.dp)
) {
Slider(
value = currentPosition.toFloat(),
onValueChange = {
isDragging = true
currentPosition = it.toLong()
},
onValueChangeFinished = {
exoPlayer.seekTo(currentPosition)
isDragging = false
},
valueRange = 0f..(duration.toFloat().coerceAtLeast(1f)),
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier.height(24.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${formatTime(currentPosition)} · ${formatTime(duration)}",
color = Color.White,
fontSize = 12.sp
)
IconButton(onClick = { /* Settings */ }) {
Icon(Icons.Rounded.Settings, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
}
}
}
}
}
}
private fun formatTime(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}

View File

@@ -1,146 +0,0 @@
package core.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.QuestionAnswer
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.knot.messager.R
@Composable
fun GlassNavigationBar(
currentRoute: String,
onNavigate: (String) -> Unit,
userAvatarUrl: String? = null,
username: String = ""
) {
// Контейнер с эффектом стекла
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.height(64.dp)
.clip(RoundedCornerShape(32.dp))
.background(Color(0xFF1C1C1E).copy(alpha = 0.95f))
.border(
0.5.dp,
Color.White.copy(alpha = 0.1f),
RoundedCornerShape(32.dp)
),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
NavItem(
icon = Icons.Default.QuestionAnswer,
label = stringResource(R.string.chats),
isSelected = currentRoute == "chat_list",
onClick = { onNavigate("chat_list") }
)
NavItem(
icon = Icons.Default.AccountCircle,
label = stringResource(R.string.contacts_tab),
isSelected = currentRoute == "contacts",
onClick = { onNavigate("contacts") }
)
NavItem(
icon = Icons.Default.Settings,
label = stringResource(R.string.settings),
isSelected = currentRoute == "settings",
onClick = { onNavigate("settings") }
)
// Профиль с аватаром
ProfileNavItem(
avatarUrl = userAvatarUrl,
username = username,
isSelected = currentRoute.startsWith("profile"),
onClick = { onNavigate("profile") }
)
}
}
}
@Composable
private fun RowScope.NavItem(
icon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
Column(
modifier = Modifier
.weight(1f)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = label,
tint = contentColor,
modifier = Modifier.size(26.dp)
)
Text(
text = label,
color = contentColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 2.dp)
)
}
}
@Composable
private fun RowScope.ProfileNavItem(
avatarUrl: String?,
username: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
Column(
modifier = Modifier
.weight(1f)
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
AppAvatar(
url = avatarUrl,
name = username,
size = 26.dp
)
Text(
text = stringResource(R.string.profile_tab),
color = contentColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 2.dp)
)
}
}

View File

@@ -1,242 +0,0 @@
package core.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
// Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh
Scaffold(
containerColor = Color(0xFF0F0F10),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back), tint = Color.White)
}
},
actions = {
IconButton(onClick = { viewModel.loadConfig() }) {
Icon(Icons.Default.Sync, contentDescription = "Refresh", tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFF0F0F10),
titleContentColor = Color.White
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
if (state.isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
color = Color(0xFF3390EC)
)
}
if (state.error != null) {
Text(
text = "Ошибка обновления: ${state.error}",
color = Color.Red,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Server Section
SettingsSection(title = stringResource(R.string.server_connection)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = stringResource(R.string.api_base_url),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3390EC)
)
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(
value = baseUrl,
onValueChange = { baseUrl = it },
modifier = Modifier.weight(1f),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedIndicatorColor = Color(0xFF3390EC),
unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f)
),
placeholder = {
Text("Например: http://192.168.1.100:5034/api/", color = Color.Gray.copy(alpha = 0.5f))
}
)
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC))
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Features Section
SettingsSection(title = stringResource(R.string.server_features)) {
Column {
SettingsToggleItem(
icon = Icons.Default.History,
label = stringResource(R.string.stories),
enabled = state.config.features.stories
)
SettingsToggleItem(
icon = Icons.Default.Poll,
label = stringResource(R.string.polls),
enabled = state.config.features.polls
)
SettingsToggleItem(
icon = Icons.Default.Call,
label = stringResource(R.string.calls),
enabled = state.config.features.calls
)
SettingsToggleItem(
icon = Icons.Default.Group,
label = stringResource(R.string.groups),
enabled = state.config.features.groups
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Limits Section
SettingsSection(title = stringResource(R.string.limits)) {
Column {
SettingsInfoItem(
icon = Icons.Default.Storage,
label = stringResource(R.string.max_file_size),
value = "${state.config.limits.maxFileSize / (1024 * 1024)} MB"
)
SettingsInfoItem(
icon = Icons.Default.People,
label = stringResource(R.string.max_group_members),
value = state.config.limits.maxGroupMembers.toString()
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Logout Button
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Default.Logout, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.log_out), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(80.dp)) // Space for bottom bar
}
}
}
@Composable
fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3390EC),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
)
Surface(
color = Color(0xFF1C1C1E),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth()
) {
content()
}
}
}
@Composable
fun SettingsToggleItem(icon: ImageVector, label: String, enabled: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = Color.White, fontSize = 16.sp)
}
Text(
text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
color = if (enabled) Color(0xFF3390EC) else Color(0xFFE53935),
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
@Composable
fun SettingsInfoItem(icon: ImageVector, label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = Color.White, fontSize = 16.sp)
}
Text(
text = value,
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
}
}

View File

@@ -1,81 +0,0 @@
package core.presentation.settings
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import core.domain.model.ServerConfigModel
import core.network.ServerConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsState(
val baseUrl: String = "",
val config: ServerConfigModel = ServerConfigModel(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val serverConfig: ServerConfig,
private val authRepository: auth.domain.repository.AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
refreshState()
// loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён
}
private fun refreshState() {
_state.update {
it.copy(
baseUrl = serverConfig.getBaseUrl(),
config = serverConfig.getServerConfig()
)
}
}
fun loadConfig() {
// Не загружаем конфиг, если URL не введён
val currentUrl = serverConfig.getBaseUrl()
if (currentUrl.isBlank()) {
android.util.Log.d("SettingsViewModel", "Base URL is empty, skipping config load")
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = authRepository.fetchConfig()
Log.d("SettingsViewModel", "Fetch result: ${result.isSuccess}")
if (result.isSuccess) {
refreshState()
} else {
_state.update { it.copy(error = result.exceptionOrNull()?.message) }
}
_state.update { it.copy(isLoading = false) }
}
}
fun saveBaseUrl(url: String) {
val currentUrl = serverConfig.getBaseUrl()
// Если URL действительно изменился, очищаем токен и логируем
if (currentUrl != url) {
android.util.Log.d("SettingsViewModel", "Server URL changed: $currentUrl -> $url, clearing token")
viewModelScope.launch {
authRepository.logout()
}
}
serverConfig.setBaseUrl(url)
_state.update { it.copy(baseUrl = url) }
}
}

View File

@@ -1,41 +0,0 @@
package core.presentation.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// Цвета из веб-версии (Dark Mode)
val DarkBackground = Color(0xFF0F0F10)
val SurfaceContainer = Color(0xFF161618)
val Primary = Color(0xFF6366F1) // Indigo/Primary из Tailwind
val PrimaryIndigo = Primary
val Indigo500 = Primary
val OnSurfaceVariant = Color(0xFF94A3B8)
private val DarkColors = darkColorScheme(
primary = Primary,
background = DarkBackground,
surface = SurfaceContainer,
onPrimary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onSurfaceVariant = OnSurfaceVariant
)
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
@Composable
fun KnotTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColors,
shapes = MaterialTheme.shapes.copy(
small = RoundedCornerShape(12.dp),
medium = SoftSquareShape,
large = RoundedCornerShape(32.dp)
),
content = content
)
}

View File

@@ -1,57 +0,0 @@
package core.security
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenManager @Inject constructor(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String, userId: String? = null, refreshToken: String? = null) {
val editor = prefs.edit().putString("jwt_token", token)
if (userId != null) {
editor.putString("user_id", userId)
}
if (refreshToken != null) {
editor.putString("refresh_token", refreshToken)
}
editor.apply()
}
fun getToken(): String? {
return prefs.getString("jwt_token", null)
}
fun getRefreshToken(): String? {
return prefs.getString("refresh_token", null)
}
fun getUserId(): String? {
return prefs.getString("user_id", null)
}
fun getUsername(): String? {
return prefs.getString("username", null)
}
fun saveUsername(username: String) {
prefs.edit().putString("username", username).apply()
}
fun deleteToken() {
prefs.edit().remove("jwt_token").remove("user_id").remove("refresh_token").apply()
}
}

View File

@@ -1,26 +0,0 @@
package core.utils
import android.content.Context
import android.os.Build
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
object CoilUtils {
private var gifImageLoader: ImageLoader? = null
fun getGifImageLoader(context: Context): ImageLoader {
if (gifImageLoader == null) {
gifImageLoader = ImageLoader.Builder(context)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
}
return gifImageLoader!!
}
}

View File

@@ -1,90 +0,0 @@
package core.utils
import android.app.DownloadManager
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object DownloadUtils {
suspend fun downloadMedia(context: Context, url: String, fileName: String?) {
withContext(Dispatchers.IO) {
try {
val name = fileName ?: url.substringAfterLast("/")
val extension = name.substringAfterLast(".", "jpg")
val isImage = extension in listOf("jpg", "jpeg", "png", "webp", "gif")
val isVideo = extension in listOf("mp4", "webm", "mkv", "mov")
if (isImage || isVideo) {
saveToGallery(context, url, name, isImage)
} else {
useDownloadManager(context, url, name)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Ошибка скачивания: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun useDownloadManager(context: Context, url: String, fileName: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("Downloading...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
// Show feedback
(context as? android.app.Activity)?.runOnUiThread {
Toast.makeText(context, "Загрузка началась: $fileName", Toast.LENGTH_SHORT).show()
}
}
private suspend fun saveToGallery(context: Context, url: String, fileName: String, isImage: Boolean) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, if (isImage) "image/jpeg" else "video/mp4")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, if (isImage) Environment.DIRECTORY_PICTURES else Environment.DIRECTORY_MOVIES)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val collection = if (isImage) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri = resolver.insert(collection, contentValues)
uri?.let {
URL(url).openStream().use { input ->
resolver.openOutputStream(it).use { output ->
input.copyTo(output!!)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(it, contentValues, null, null)
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Сохранено в галерею", Toast.LENGTH_SHORT).show()
}
}
}
}

View File

@@ -1,31 +0,0 @@
package core.utils
import android.content.Context
import android.net.Uri
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
fun copyUriToFile(context: Context, uri: Uri): File? {
return try {
val cursor = context.contentResolver.query(uri, null, null, null, null)
val originalName = cursor?.use {
if (it.moveToFirst()) {
val index = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (index != -1) it.getString(index) else null
} else null
} ?: "${UUID.randomUUID()}.tmp"
val inputStream = context.contentResolver.openInputStream(uri)
val file = File(context.cacheDir, originalName)
val outputStream = FileOutputStream(file)
inputStream?.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
file
} catch (e: Exception) {
null
}
}

Some files were not shown because too many files have changed in this diff Show More