first commit

This commit is contained in:
Anton Budylin
2026-04-14 10:12:51 +03:00
commit ea171ed95a
247 changed files with 42642 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
android {
namespace = "ru.lastochka.messenger"
compileSdk = 35
defaultConfig {
applicationId = "ru.lastochka.messenger"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
debug {
resValue("string", "default_host_name", "app.lastochka-m.ru")
resValue("string", "default_api_key", "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K")
}
release {
resValue("string", "default_host_name", "app.lastochka-m.ru")
resValue("string", "default_api_key", "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K")
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.15"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.animation:animation")
// Activity Compose
implementation("androidx.activity:activity-compose:1.9.3")
// Navigation Compose
implementation("androidx.navigation:navigation-compose:2.8.3")
// ViewModel + Lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.52")
ksp("com.google.dagger:hilt-compiler:2.52")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Material Components (for themes/splash)
implementation("com.google.android.material:material:1.12.0")
// Coil (image loading)
implementation("io.coil-kt:coil-compose:2.7.0")
// ExifInterface (image orientation)
implementation("androidx.exifinterface:exifinterface:1.3.7")
// DataStore (preferences)
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Core Splash Screen
implementation("androidx.core:core-splashscreen:1.0.1")
// Timber (logging)
implementation("com.jakewharton.timber:timber:5.0.1")
// Firebase (Push Notifications)
implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// Gson (JSON parsing)
implementation("com.google.code.gson:gson:2.11.0")
// OkHttp (WebSocket + HTTP)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.10")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("app.cash.turbine:turbine:1.1.0")
testImplementation("org.robolectric:robolectric:4.12.1")
testImplementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
testImplementation("androidx.savedstate:savedstate:1.2.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.room:room-testing:2.6.1")
}

View File

@@ -0,0 +1,26 @@
# ProGuard rules for Lastochka Messenger
# Tinode SDK
-keep class co.tinode.tinodesdk.** { *; }
-keep class co.tinode.tinodesdk.model.** { *; }
# Gson
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.google.gson.** { *; }
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
# Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".LastochkaApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Firebase Messaging Service -->
<service
android:name=".service.LastochkaFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- FileProvider для камеры -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,88 @@
package ru.lastochka.messenger
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import dagger.hilt.android.HiltAndroidApp
import ru.lastochka.messenger.data.TinodeClient
import ru.lastochka.messenger.data.local.AppDatabase
import ru.lastochka.messenger.di.createImageLoader
import ru.lastochka.messenger.service.NetworkMonitor
import timber.log.Timber
import javax.inject.Inject
/**
* Приложение Ласточка — инициализация Tinode SDK и Room DB.
*/
@HiltAndroidApp
class LastochkaApp : Application(), ImageLoaderFactory {
@Inject lateinit var networkMonitor: NetworkMonitor
lateinit var tinodeClient: TinodeClient
private set
/** Получить base URL для загрузки файлов (http:// или https:// + host) */
fun getFileBaseUrl(): String {
return tinodeClient.getFileBaseUrl()
}
lateinit var database: AppDatabase
private set
private lateinit var _imageLoader: ImageLoader
override fun onCreate() {
super.onCreate()
// Инициализация Timber для логирования
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
// В production можно добавить CrashlyticsTree или свою реализацию
// Timber.plant(CrashlyticsTree())
}
Timber.d("LastochkaApp onCreate")
// Инициализация Room
database = AppDatabase.getInstance(this)
// Инициализация Tinode SDK
// Dev сервер: ws:// (без TLS), production: wss://
val isDev = getString(R.string.default_host_name).contains("10.0.2.2") ||
getString(R.string.default_host_name).contains("localhost")
tinodeClient = TinodeClient(
context = this,
appName = APP_NAME,
apiKey = getString(R.string.default_api_key),
hostName = getString(R.string.default_host_name),
useTLS = !isDev
)
// Запуск мониторинга сети (автореконнект)
networkMonitor.startMonitoring()
// Инициализация ImageLoader для Coil с авторизацией Tinode
_imageLoader = createImageLoader(this, tinodeClient)
}
/**
* ImageLoaderFactory — Coil использует этот ImageLoader по умолчанию
* для всех AsyncImage/SubcomposeAsyncImage вызовов.
*/
override fun newImageLoader(): ImageLoader = _imageLoader
/** Получить ImageLoader для использования в Compose */
fun getImageLoader(): ImageLoader = _imageLoader
override fun onTerminate() {
super.onTerminate()
networkMonitor.stopMonitoring()
tinodeClient.disconnect()
}
companion object {
const val APP_NAME = "Ласточка"
}
}

View File

@@ -0,0 +1,333 @@
package ru.lastochka.messenger
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import androidx.navigation.navArgument
import dagger.hilt.android.AndroidEntryPoint
import ru.lastochka.messenger.data.AuthState
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.SessionRepository
import ru.lastochka.messenger.navigation.Screen
import ru.lastochka.messenger.ui.screens.auth.LoginScreen
import ru.lastochka.messenger.ui.screens.auth.RegisterScreen
import ru.lastochka.messenger.ui.screens.calls.CallsScreen
import ru.lastochka.messenger.ui.screens.chat.ChatScreen
import ru.lastochka.messenger.ui.screens.contact.ContactInfoScreen
import ru.lastochka.messenger.ui.screens.groups.CreateGroupScreen
import ru.lastochka.messenger.ui.screens.chatlist.ChatListScreen
import ru.lastochka.messenger.ui.screens.newchat.NewChatScreen
import ru.lastochka.messenger.ui.screens.settings.ProfileScreen
import ru.lastochka.messenger.ui.screens.settings.SettingsScreen
import ru.lastochka.messenger.ui.theme.LastochkaTheme
import ru.lastochka.messenger.viewmodel.AuthViewModel
import ru.lastochka.messenger.viewmodel.ChatListViewModel
import javax.inject.Inject
/**
* Главная Activity — точка входа в приложение.
* Управляет навигацией между экранами.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var chatRepository: ChatRepository
@Inject lateinit var sessionRepository: SessionRepository
override fun onCreate(savedInstanceState: Bundle?) {
// Устанавливаем splash screen ДО super.onCreate()
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
// Splash screen видимость управляется auth состоянием.
// Держим splash пока проверяется сохранённая сессия.
var keepSplashOnScreen = true
splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
setContent {
LastochkaTheme {
// AuthState как StateFlow — реагирует на logout/login
val authState by sessionRepository.authState.collectAsState()
val isAuthenticated = authState is AuthState.Authenticated
// 1) Нет сохранённой сессии → сразу убираем splash (показать Login)
LaunchedEffect(Unit) {
val hasSaved = sessionRepository.hasSavedSession()
if (!hasSaved) {
keepSplashOnScreen = false
}
}
// 2) AutoLogin завершился (успех или ошибка) → убираем splash
LaunchedEffect(authState) {
if (authState !is AuthState.Unauthenticated) {
keepSplashOnScreen = false
}
}
// 3) Safety timeout — максимум 3 секунды
LaunchedEffect(Unit) {
delay(3000)
keepSplashOnScreen = false
}
if (isAuthenticated) {
MainAppScreen(
onLogoutRequired = {
// Logout через SessionRepository
kotlinx.coroutines.GlobalScope.launch {
sessionRepository.logout()
}
}
)
} else {
AuthNavHost(
onLoginSuccess = {
// Не recreate() — просто ждём пока authState обновится
// SessionRepository.login() уже установил _authState = Authenticated
}
)
}
}
}
}
@Composable
fun AuthNavHost(onLoginSuccess: () -> Unit) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Login.route,
enterTransition = { slideInHorizontally { it } },
exitTransition = { slideOutHorizontally { -it } }
) {
composable(Screen.Login.route) {
LoginScreen(
onLoginSuccess = onLoginSuccess,
onNavigateToRegister = {
navController.navigate(Screen.Register.route)
}
)
}
composable(Screen.Register.route) {
RegisterScreen(
onRegisterSuccess = onLoginSuccess,
onNavigateToLogin = {
navController.popBackStack()
}
)
}
}
}
@Composable
fun MainAppScreen(onLogoutRequired: () -> Unit) {
val navController = rememberNavController()
var selectedTab by remember { mutableStateOf(0) }
val chatListViewModel: ChatListViewModel = hiltViewModel()
val totalUnread by chatListViewModel.totalUnread.collectAsState()
// Определяем текущий маршрут для подсветки таба
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
LaunchedEffect(currentRoute) {
selectedTab = when {
currentRoute?.startsWith(Screen.Chats.route) == true -> 0
currentRoute == Screen.Calls.route -> 1
currentRoute?.startsWith(Screen.Settings.route) == true -> 2
else -> selectedTab
}
}
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
navController.navigate(Screen.Chats.route) {
popUpTo(Screen.Chats.route) { inclusive = false }
launchSingleTop = true
}
},
icon = {
if (totalUnread > 0) {
Badge(
modifier = Modifier.offset(x = 8.dp, y = (-8).dp)
) {
Text(
text = if (totalUnread > 99) "99+" else "$totalUnread",
style = MaterialTheme.typography.labelSmall
)
}
}
Icon(Icons.Default.Chat, null)
},
label = { Text("Чаты", fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal) }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
navController.navigate(Screen.Calls.route) {
popUpTo(Screen.Calls.route) { inclusive = false }
launchSingleTop = true
}
},
icon = { Icon(Icons.Default.Call, null) },
label = { Text("Звонки", fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal) }
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = {
selectedTab = 2
navController.navigate(Screen.Settings.route) {
popUpTo(Screen.Settings.route) { inclusive = false }
launchSingleTop = true
}
},
icon = { Icon(Icons.Default.Settings, null) },
label = { Text("Настройки", fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal) }
)
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = Screen.Chats.route,
modifier = Modifier.padding(padding)
) {
// --- Chats Graph ---
composable(Screen.Chats.route) {
ChatListScreen(
onChatClick = { topicName, topicTitle ->
navController.navigate(Screen.Chat.createRoute(topicName, topicTitle))
},
onNewChat = {
navController.navigate(Screen.NewChat.route)
},
onProfile = {
navController.navigate(Screen.Profile.route)
},
onLogout = onLogoutRequired
)
}
composable(
route = Screen.Chat.route,
arguments = listOf(
navArgument("topicName") { type = NavType.StringType },
navArgument("topicTitle") {
type = NavType.StringType
defaultValue = ""
}
)
) { backStackEntry ->
val topicName = backStackEntry.arguments?.getString("topicName") ?: ""
val topicTitleArg = backStackEntry.arguments?.getString("topicTitle") ?: ""
// Если title пустой или равен topicName — загружаем настоящее имя из контакта
val effectiveTitle = if (topicTitleArg.isBlank() || topicTitleArg == topicName) {
null // Загрузим из сервера
} else {
topicTitleArg
}
ChatScreen(
topicName = topicName,
topicTitle = effectiveTitle ?: topicName,
onBack = { navController.popBackStack() },
onOpenContactInfo = {
navController.navigate(Screen.ContactInfo.createRoute(topicName, topicTitleArg.ifBlank { topicName }))
}
)
}
composable(
route = Screen.ContactInfo.route,
arguments = listOf(
navArgument("topicName") { type = NavType.StringType },
navArgument("topicTitle") { type = NavType.StringType }
)
) { backStackEntry ->
val topicName = backStackEntry.arguments?.getString("topicName") ?: ""
val topicTitle = backStackEntry.arguments?.getString("topicTitle") ?: ""
ContactInfoScreen(
topicName = topicName,
topicTitle = topicTitle,
onBack = { navController.popBackStack() },
onNavigateToChat = {
navController.navigate(Screen.Chat.createRoute(topicName, topicTitle)) {
popUpTo(Screen.ContactInfo.route) { inclusive = true }
}
}
)
}
composable(Screen.CreateGroup.route) {
CreateGroupScreen(
onGroupCreated = { topicName ->
navController.navigate(Screen.Chat.createRoute(topicName, topicName)) {
popUpTo(Screen.CreateGroup.route) { inclusive = true }
}
},
onBack = { navController.popBackStack() }
)
}
composable(Screen.NewChat.route) {
NewChatScreen(
onChatSelected = { contactInfo ->
navController.navigate(Screen.Chat.createRoute(contactInfo.topicName, contactInfo.displayName)) {
popUpTo(Screen.Chats.route)
}
},
onCreateGroup = {
navController.navigate(Screen.CreateGroup.route)
},
onCreateChannel = {
// TODO: Navigate to CreateChannel
},
onBack = { navController.popBackStack() }
)
}
// --- Calls Graph ---
composable(Screen.Calls.route) {
CallsScreen()
}
// --- Settings Graph ---
composable(Screen.Settings.route) {
SettingsScreen(
onNavigateToProfile = {
navController.navigate(Screen.Profile.route)
},
onLogout = onLogoutRequired
)
}
composable(Screen.Profile.route) {
ProfileScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
package ru.lastochka.messenger.data
import kotlinx.coroutines.flow.Flow
import ru.lastochka.messenger.data.local.AppDatabase
import ru.lastochka.messenger.data.local.ContactEntity
import ru.lastochka.messenger.data.local.MessageEntity
/**
* Repository — единая точка доступа к данным (Tinode + Room).
*/
class ChatRepository(
private val tinodeClient: TinodeClient,
private val database: AppDatabase,
private val sessionRepository: SessionRepository
) {
// ─── Messages ───────────────────────────────────────────────
fun getMessages(topicName: String): Flow<List<MessageEntity>> {
return database.messageDao().getMessagesForTopic(topicName)
}
suspend fun saveMessage(entity: MessageEntity) {
database.messageDao().insertMessage(entity)
}
suspend fun saveMessages(entities: List<MessageEntity>) {
database.messageDao().insertMessages(entities)
}
suspend fun markAllRead(topicName: String) {
database.messageDao().markAllRead(topicName)
val maxSeq = database.messageDao().getMaxSeq(topicName)
if (maxSeq != null) {
tinodeClient.markAsRead(topicName, maxSeq)
}
}
// ─── Contacts ────────────────────────────────────────────────
fun getContacts(): Flow<List<ContactEntity>> {
return database.contactDao().getAllContacts()
}
suspend fun saveContacts(contacts: List<ContactEntity>) {
database.contactDao().insertContacts(contacts)
}
suspend fun clearUnread(topicName: String) {
database.contactDao().clearUnread(topicName)
}
// ─── Session / Auth (делегирование SessionRepository) ───────
val authState: Flow<AuthState>
get() = sessionRepository.authState
val myUid: String?
get() = sessionRepository.myUid
fun isAuthenticated(): Boolean = sessionRepository.isAuthenticated
val connectionState: Flow<TinodeConnState>
get() = sessionRepository.connectionState
suspend fun login(username: String, password: String): Result<Unit> {
return sessionRepository.login(username, password)
}
suspend fun register(username: String, password: String, displayName: String): Result<Unit> {
return sessionRepository.register(username, password, displayName)
}
suspend fun registerWithFullProfile(
username: String, password: String, displayName: String,
email: String, phone: String
): Result<Unit> {
return sessionRepository.registerWithFullProfile(username, password, displayName, email, phone)
}
suspend fun autoLogin(): Result<Unit> {
return sessionRepository.autoLogin()
}
suspend fun logout() {
sessionRepository.logout()
}
fun hasSavedToken(): Boolean = sessionRepository.isAuthenticated
// ─── Tinode operations ───────────────────────────────────────
suspend fun subscribeTopic(topicName: String): Result<Unit> {
return tinodeClient.subscribeTopic(topicName)
}
suspend fun getMeTopic(): List<ru.lastochka.messenger.data.model.MetaSub> {
return tinodeClient.getMeTopic()
}
fun getContactsFromSubs(subs: List<ru.lastochka.messenger.data.model.MetaSub>): List<ContactInfo> {
return tinodeClient.getContacts(subs)
}
suspend fun getTopicTitle(topicName: String): String {
return tinodeClient.getTopicTitle(topicName)
}
fun sendTextMessage(topicName: String, text: String) {
tinodeClient.sendTextMessage(topicName, text)
}
suspend fun sendImageMessage(
topicName: String,
imageUri: android.net.Uri,
mimeType: String,
fileName: String,
caption: String = ""
): Result<String> {
return tinodeClient.sendImageMessage(topicName, imageUri, mimeType, fileName, caption)
}
suspend fun sendImageMessageWithProgress(
topicName: String,
imageUri: android.net.Uri,
mimeType: String,
fileName: String,
caption: String = "",
fileSize: Long = 0,
onProgress: (Float) -> Unit
): Result<String> {
return tinodeClient.sendImageMessageWithProgress(
topicName, imageUri, mimeType, fileName, caption, fileSize, onProgress
)
}
fun sendTyping(topicName: String) {
tinodeClient.sendTyping(topicName)
}
fun markAsRead(topicName: String, seq: Int) {
tinodeClient.markAsRead(topicName, seq)
}
suspend fun searchUsers(query: String): List<ContactInfo> {
return tinodeClient.searchUsers(query)
}
suspend fun startChatWithUser(topicName: String): Result<Unit> {
return tinodeClient.startChatWithUser(topicName)
}
suspend fun loadMessagesBefore(topicName: String, beforeSeq: Int, limit: Int) {
tinodeClient.loadMessagesBefore(topicName, beforeSeq, limit)
// Сообщения приходят через event flow и сохраняются в Room через ChatViewModel
}
suspend fun deleteMessage(topicName: String, seqId: Int): Result<Unit> {
return tinodeClient.deleteMessage(topicName, seqId)
}
fun editMessage(topicName: String, seqId: Int, newText: String) {
tinodeClient.editMessage(topicName, seqId, newText)
}
suspend fun updateProfile(name: String, bio: String): Result<Unit> {
return tinodeClient.updateProfile(name, bio)
}
suspend fun getMyProfile(): Result<ru.lastochka.messenger.data.UserProfile> {
return tinodeClient.getMyProfile()
}
suspend fun createGroup(name: String, description: String, members: List<String>): Result<String> {
return tinodeClient.createGroup(name, description, members)
}
val events = tinodeClient.events
}

View File

@@ -0,0 +1,192 @@
package ru.lastochka.messenger.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import ru.lastochka.messenger.service.NetworkMonitor
import ru.lastochka.messenger.util.RetryPolicy
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "session")
sealed class AuthState {
data object Unauthenticated : AuthState()
data class Authenticated(val uid: String) : AuthState()
data object SessionExpired : AuthState()
}
/**
* Простой и надёжный auth-менеджер.
*
* Принципы:
* 1. _authState — единственный источник правды
* 2. login() → при успехе СРАЗУ _authState = Authenticated
* 3. DataStore — только для автологина при перезапуске
* 4. Никаких combine, race condition и сложных flow
*/
@Singleton
class SessionRepository @Inject constructor(
private val context: Context,
val tinodeClient: TinodeClient,
private val networkMonitor: NetworkMonitor
) {
private val dataStore: DataStore<Preferences> = context.dataStore
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// ─── Auth State — полностью независим от connectionState ─
private val _authState = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
val authState: StateFlow<AuthState> = _authState.asStateFlow()
// ─── Proxy-свойства ──────────────────────────────────────
val myUid: String? get() = tinodeClient.myUid
val isAuthenticated: Boolean
get() = _authState.value is AuthState.Authenticated
val connectionState: StateFlow<TinodeConnState> = tinodeClient.connectionState
init {
// НЕ зависим от connectionState для authState.
// authState управляется ТОЛЬКО через login()/logout()/autoLogin()
scope.launch {
val savedUid = loadUid()
if (savedUid != null) {
// Есть сохранённая сессия — пробуем автологин
// autoLogin() сам подключится и обновит _authState
autoLogin()
}
}
// При восстановлении сети — переподключаемся если были авторизованы
scope.launch {
networkMonitor.isConnected.collect { connected ->
if (connected && _authState.value is AuthState.Authenticated) {
if (tinodeClient.connectionState.value == TinodeConnState.Disconnected) {
autoLogin()
}
}
}
}
}
// ─── Auth Operations ─────────────────────────────────────
suspend fun login(username: String, password: String): Result<Unit> {
Timber.d("Attempting login for user: $username")
val result = tinodeClient.login(username, password)
Timber.d("Login result: $result, myUid=${tinodeClient.myUid}")
if (result.isSuccess) {
val uid = tinodeClient.myUid ?: username
Timber.d("Setting authState = Authenticated($uid)")
_authState.value = AuthState.Authenticated(uid)
saveUid(uid)
} else {
Timber.w("Login failed: ${result.exceptionOrNull()?.message}")
}
return result
}
suspend fun register(username: String, password: String, displayName: String): Result<Unit> {
Timber.d("Attempting registration for user: $username")
val result = tinodeClient.register(username, password, displayName)
if (result.isSuccess) {
val uid = tinodeClient.myUid ?: username
Timber.d("Registration successful, uid=$uid")
saveUid(uid)
_authState.value = AuthState.Authenticated(uid)
} else {
Timber.w("Registration failed: ${result.exceptionOrNull()?.message}")
}
return result
}
suspend fun registerWithFullProfile(
username: String, password: String, displayName: String,
email: String, phone: String
): Result<Unit> {
Timber.d("Attempting registration with full profile for user: $username")
val result = tinodeClient.registerWithFullProfile(username, password, displayName, email, phone)
if (result.isSuccess) {
val uid = tinodeClient.myUid ?: username
saveUid(uid)
_authState.value = AuthState.Authenticated(uid)
} else {
Timber.w("Registration failed: ${result.exceptionOrNull()?.message}")
}
return result
}
suspend fun logout() {
Timber.w("logout() called! Clearing auth state")
tinodeClient.logout()
clearUid()
_authState.value = AuthState.Unauthenticated
}
suspend fun autoLogin(): Result<Unit> {
val policy = RetryPolicy(
maxRetries = 3,
initialDelayMs = 1_000L,
maxDelayMs = 10_000L,
backoffFactor = 2.0,
shouldRetry = { e ->
// Не retry если токен недействителен
e.message?.contains("401", ignoreCase = true) != true &&
e.message?.contains("token", ignoreCase = true) != true
}
)
return policy.invoke {
val result = tinodeClient.autoLogin()
if (result.isSuccess) {
val uid = tinodeClient.myUid ?: loadUid() ?: ""
saveUid(uid)
_authState.value = AuthState.Authenticated(uid)
} else {
clearUid()
}
result
}
}
// ─── Persistence ─────────────────────────────────────────
fun hasSavedToken(): Boolean = _authState.value is AuthState.Authenticated
/** Проверить есть ли сохранённая сессия (UID в DataStore). */
suspend fun hasSavedSession(): Boolean {
return try {
dataStore.data.first()[KEY_UID] != null
} catch (_: Exception) {
false
}
}
private suspend fun saveUid(uid: String) {
try {
dataStore.edit { it[KEY_UID] = uid }
} catch (_: Exception) {}
}
private suspend fun loadUid(): String? {
return try {
dataStore.data.first()[KEY_UID]
} catch (_: Exception) {
null
}
}
private suspend fun clearUid() {
try {
dataStore.edit { it.remove(KEY_UID) }
} catch (_: Exception) {}
}
companion object {
private val KEY_UID = stringPreferencesKey("my_uid")
}
}

View File

@@ -0,0 +1,685 @@
package ru.lastochka.messenger.data
import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okhttp3.*
import okhttp3.EventListener
import okhttp3.Handshake
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okio.BufferedSink
import ru.lastochka.messenger.data.model.*
import timber.log.Timber
import java.io.File
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random
/**
* Низкоуровневый клиент Tinode: WebSocket для real-time.
*/
class TinodeHttpClient(
private val context: Context,
private val apiKey: String,
private val hostName: String,
private val appName: String,
private val useTLS: Boolean = true
) {
val gson: Gson = GsonBuilder()
// НЕ включаем null поля — пустые объекты {} для sub/desc
.registerTypeAdapter(PubContent::class.java, PubContentDeserializer)
.create()
private var webSocket: WebSocket? = null
private val _connectionEvents = MutableSharedFlow<ConnectionEvent>(extraBufferCapacity = 8)
@Volatile var authToken: String? = null
@Volatile var myUid: String? = null
@Volatile var isConnected = false
@Volatile var isAuthenticated = false
private val pendingRequests = mutableMapOf<String, (ServerMessage) -> Unit>()
private var _eventCallback: ((ServerMessage) -> Unit)? = null
private val scope = CoroutineScope(Dispatchers.IO + Job())
val connectionEvents: SharedFlow<ConnectionEvent> = _connectionEvents
fun setEventCallback(callback: (ServerMessage) -> Unit) {
_eventCallback = callback
}
// ─── Connection ─────────────────────────────────────────────
fun connect() {
if (isConnected) return
val scheme = if (useTLS) "wss" else "ws"
val wsUrl = "$scheme://$hostName/v0/channels?apikey=$apiKey"
Timber.d("Connecting to $wsUrl")
val request = Request.Builder()
.url(wsUrl)
.build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isConnected = true
scope.launch { _connectionEvents.emit(ConnectionEvent.Connected) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val msg = gson.fromJson(text, ServerMessage::class.java)
Timber.d("<<< RAW: ${text.take(200)}")
Timber.d("<<< TYPE: ${msg.type}, id=${msg.ctrl?.id ?: msg.data?.topic ?: msg.meta?.topic ?: "unknown"}")
when (msg.type) {
MsgType.CTRL -> {
val ctrl = msg.ctrl ?: run {
Timber.w("CTRL message without ctrl field: $text")
return
}
ctrl.id?.let { id ->
pendingRequests.remove(id)?.invoke(msg)
}
ctrl.params?.token?.let { token -> authToken = token }
ctrl.params?.user?.let { uid -> myUid = uid }
// ВАЖНО: НЕ ставим isAuthenticated здесь — только в login()!
// hi() возвращает 200, но это НЕ аутентификация
}
MsgType.DATA, MsgType.META, MsgType.PRES, MsgType.INFO -> {
_eventCallback?.invoke(msg)
}
else -> {}
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse WebSocket message")
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
isAuthenticated = false
authToken = null
myUid = null
scope.launch { _connectionEvents.emit(ConnectionEvent.Disconnected) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val errorMsg = response?.message ?: t.message ?: "Unknown error"
val statusCode = response?.code ?: 0
Timber.e(t, "WebSocket onFailure: code=$statusCode, message=$errorMsg")
isConnected = false
isAuthenticated = false
authToken = null
myUid = null
scope.launch { _connectionEvents.emit(ConnectionEvent.Error(t)) }
}
}
val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.eventListener(object : EventListener() {
override fun dnsStart(call: Call, domainName: String) {
Timber.d("WS dnsStart: $domainName")
}
override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<java.net.InetAddress>) {
Timber.d("WS dnsEnd: ${inetAddressList.map { it.hostAddress }}")
}
override fun connectStart(call: Call, inetSocketAddress: java.net.InetSocketAddress, proxy: java.net.Proxy) {
Timber.d("WS connectStart: ${inetSocketAddress.hostName}:${inetSocketAddress.port}")
}
override fun secureConnectStart(call: Call) {
Timber.d("WS secureConnectStart (TLS handshake)")
}
override fun secureConnectEnd(call: Call, handshake: Handshake?) {
Timber.d("WS secureConnectEnd: ${handshake?.tlsVersion}")
}
override fun connectFailed(call: Call, inetSocketAddress: java.net.InetSocketAddress, proxy: java.net.Proxy, protocol: okhttp3.Protocol?, ioe: java.io.IOException) {
Timber.e(ioe, "WS connectFailed: ${inetSocketAddress.hostName}:${inetSocketAddress.port}")
}
override fun callFailed(call: Call, ioe: java.io.IOException) {
Timber.e(ioe, "WS callFailed: ${ioe.message}")
}
})
.build()
webSocket = client.newWebSocket(request, listener)
}
fun disconnect() {
webSocket?.close(1000, "Client disconnect")
webSocket = null
isConnected = false
isAuthenticated = false
authToken = null
myUid = null
}
// ─── Messages ──────────────────────────────────────────────
fun send(message: Any): String {
val json = gson.toJson(message)
Timber.d(">>> RAW: ${json.take(150)}")
webSocket?.send(json)
// Extract ID from the nested message structure (hi.id, acc.id, login.id, etc.)
return extractMessageId(message)
}
/**
* Отправить сообщение и ждать ответа по id (без suspend).
*/
fun sendWithDirectCallback(message: Any, id: String) {
pendingRequests[id] = { msg ->
Timber.d("Direct callback received for id=$id")
}
val json = gson.toJson(message)
Timber.d(">>> RAW: ${json.take(150)}")
webSocket?.send(json)
}
private fun extractMessageId(message: Any): String {
// Try to extract ID from common Tinode message structures
val json = gson.toJsonTree(message).asJsonObject
// Check for nested id fields: hi.id, acc.id, login.id, sub.id, pub.id, note.id, leave.id, get.id, set.id
for (key in listOf("hi", "acc", "login", "sub", "pub", "note", "leave", "get", "set")) {
val nested = json.get(key)
if (nested != null && nested.isJsonObject) {
val id = nested.asJsonObject.get("id")
if (id != null && !id.isJsonNull) {
return id.asString
}
}
}
// Fallback: generate ID if not found
return generateId()
}
private suspend fun sendWithCallback(message: Any): ServerMessage = suspendCancellableCoroutine { cont ->
if (!isConnected) {
cont.resumeWith(Result.failure(Exception("Нет подключения к серверу")))
return@suspendCancellableCoroutine
}
val id = send(message)
pendingRequests[id] = { msg ->
cont.resume(msg, null)
}
scope.launch {
delay(30_000)
if (!cont.isCompleted) {
pendingRequests.remove(id)
cont.resumeWith(Result.failure(Exception("Сервер не отвечает. Проверьте интернет-соединение.")))
}
}
}
// ─── API methods ────────────────────────────────────────────
suspend fun sendHi(): ServerMessage {
val msg = ClientMsgHi(hi = HiPacket(id = generateId()))
return sendWithCallback(msg)
}
/**
* Неблокирующая отправка hi — просто отправляем JSON.
*/
fun sendHiNonBlocking() {
val msg = ClientMsgHi(hi = HiPacket(id = generateId()))
send(msg)
}
suspend fun register(username: String, password: String, displayName: String): ServerMessage {
val msg = ClientMsgAcc(
acc = AccPacket(
id = generateId(),
user = "new",
scheme = "basic",
secret = base64Encode("$username:$password"),
login = true,
desc = DescPacket(`public` = TheCard(fn = displayName))
)
)
return sendWithCallback(msg)
}
suspend fun registerWithFullProfile(
username: String,
password: String,
displayName: String,
email: String,
phone: String
): ServerMessage {
// НЕ отправляем tags при регистрации — сервер отклоняет 403
// Теги можно установить позже через setMeta("me")
val tags: List<String>? = null
val credentials = mutableListOf<Credential>()
if (email.isNotBlank()) {
credentials.add(Credential(meth = "email", val_str = email, done = false))
}
val msg = ClientMsgAcc(
acc = AccPacket(
id = generateId(),
user = "new",
scheme = "basic",
secret = base64Encode("$username:$password"),
login = true,
desc = DescPacket(`public` = TheCard(fn = displayName)),
tags = tags,
cred = credentials.ifEmpty { null }
)
)
return sendWithCallback(msg)
}
suspend fun login(username: String, password: String): ServerMessage {
val msg = ClientMsgLogin(
login = LoginPacket(id = generateId(), scheme = "basic", secret = base64Encode("$username:$password"))
)
return sendWithCallback(msg)
}
suspend fun loginToken(token: String): ServerMessage {
val msg = ClientMsgLogin(
login = LoginPacket(id = generateId(), scheme = "token", secret = token)
)
return sendWithCallback(msg)
}
suspend fun subscribe(topicName: String, get: MetaGetPacket? = null): ServerMessage {
val msg = ClientMsgSub(sub = SubPacket(id = generateId(), topic = topicName, get = get))
return sendWithCallback(msg)
}
suspend fun getData(topicName: String, since: Int = 0, limit: Int = 100): ServerMessage {
val msg = ClientMsgGet(get = GetPacket(id = generateId(), topic = topicName, data = MetaGetData(since = since, limit = limit)))
return sendWithCallback(msg)
}
suspend fun getMeta(topicName: String, what: String = "desc sub"): ServerMessage {
val msg = ClientMsgGet(get = GetPacket(id = generateId(), topic = topicName, what = what))
// Для get запросов сервер возвращает ctrl + meta как отдельные сообщения
// sendWithCallback дождётся ctrl, а meta придёт через event callback
return try {
withTimeout(10_000) {
sendWithCallback(msg)
}
} catch (e: Exception) {
// Если ctrl не пришёл (таймаут) — пробуем ещё раз
Timber.w(e, "getMeta sendWithCallback timeout, retrying...")
send(msg) // Отправляем ещё раз
delay(500) // Даём время на ответ
ServerMessage() // Возвращаем пустой — meta придёт через callback
}
}
suspend fun setMeta(topicName: String, set: MetaSetPacket): ServerMessage {
val msg = ClientMsgSet(set = SetPacket(id = generateId(), topic = topicName, desc = set.desc, sub = set.sub))
return sendWithCallback(msg)
}
fun publish(topicName: String, text: String) {
send(ClientMsgPub(pub = PubPacket(id = generateId(), topic = topicName, content = PubContent(txt = text))))
}
/**
* Отправить сообщение с Drafty контентом и extra attachments.
*/
fun publishWithContent(topicName: String, contentJson: String, mime: String, extra: PubExtra? = null) {
// Сериализуем вручную чтобы отправить правильный JSON
val pubPacket = mutableMapOf<String, Any>(
"id" to generateId(),
"topic" to topicName,
"head" to mapOf("mime" to mime),
"content" to gson.fromJson(contentJson, Map::class.java)
)
// extra.attachments — опционально, для garbage collection на сервере
extra?.let {
pubPacket["extra"] = mapOf("attachments" to it.attachments)
}
val msg = mapOf("pub" to pubPacket)
val json = gson.toJson(msg)
Timber.d(">>> Pub with attachments: ${json.take(200)}...")
webSocket?.send(json)
}
fun sendTyping(topicName: String) {
send(ClientMsgNote(note = NotePacket(id = generateId(), topic = topicName, what = "kp")))
}
fun noteRead(topicName: String, seq: Int) {
send(ClientMsgNote(note = NotePacket(id = generateId(), topic = topicName, what = "read", seq = seq)))
}
fun leave(topicName: String) {
send(ClientMsgLeave(leave = LeavePacket(id = generateId(), topic = topicName)))
}
fun leaveAndUnsub(topicName: String) {
send(ClientMsgLeave(leave = LeavePacket(id = generateId(), topic = topicName, unsub = true)))
}
/**
* Смена пароля текущего пользователя.
* Tinode: acc message с user="me" и новым secret.
*/
suspend fun changePassword(username: String, oldPassword: String, newPassword: String): ServerMessage {
val msg = ClientMsgAcc(
acc = AccPacket(
id = generateId(),
user = "me",
scheme = "basic",
secret = base64Encode("$username:$newPassword"),
login = false
)
)
return sendWithCallback(msg)
}
/**
* Удалить сообщение (del message).
* Tinode: del message с hard=true для полного удаления.
*/
suspend fun deleteMessage(topicName: String, seqId: Int): ServerMessage {
val msg = ClientMsgDel(del = DelPacket(
id = generateId(),
topic = topicName,
seq = DelSeq(first = seqId, last = seqId),
hard = true
))
return sendWithCallback(msg)
}
/**
* Редактировать сообщение (pub с head.replace).
* Tinode: pub message с head.replace = seqId оригинала.
*/
fun editMessage(topicName: String, seqId: Int, newText: String) {
val msg = ClientMsgPub(pub = PubPacket(
id = generateId(),
topic = topicName,
content = PubContent(txt = newText),
head = PubHead(replaces = seqId.toString())
))
send(msg)
}
// ─── Helpers ────────────────────────────────────────────────
private fun generateId(): String {
val bytes = ByteArray(8)
Random.nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun base64Encode(input: String): String {
return Base64.getEncoder().encodeToString(input.toByteArray(Charsets.UTF_8))
}
// ─── File Upload ─────────────────────────────────────────────
/**
* Загрузить файл на сервер Tinode через HTTP POST multipart.
* Возвращает URL файла относительно /v0/file/s/.
*/
suspend fun uploadFile(uri: android.net.Uri, mimeType: String, fileName: String): String {
val scheme = if (useTLS) "https" else "http"
val httpUrl = okhttp3.HttpUrl.Builder()
.scheme(scheme)
.host(hostName)
.addEncodedPathSegments("v0/file/u/")
.addQueryParameter("apikey", apiKey)
.addQueryParameter("auth", "token")
.addQueryParameter("secret", authToken ?: "")
.build()
Timber.d("Uploading file to $httpUrl, mime=$mimeType, name=$fileName")
return uploadFileToUrl(uri, mimeType, fileName, httpUrl.toString())
}
/**
* Загрузить файл с прогресс-колбэком через SDK LargeFileHelper.
* Колбэк возвращает прогресс 0.0..1.0.
*/
suspend fun uploadFileWithProgress(
uri: android.net.Uri,
mimeType: String,
fileName: String,
onProgress: (Float) -> Unit
): String {
// Используем HttpUrl.Builder для корректного URL-encoding параметров
val scheme = if (useTLS) "https" else "http"
val httpUrl = okhttp3.HttpUrl.Builder()
.scheme(scheme)
.host(hostName)
.addEncodedPathSegments("v0/file/u/")
.addQueryParameter("apikey", apiKey)
.addQueryParameter("auth", "token")
.addQueryParameter("secret", authToken ?: "")
.build()
val uploadUrl = httpUrl.toString()
Timber.d("Uploading file with progress: $uploadUrl")
return uploadFileToUrlWithProgress(uri, mimeType, fileName, uploadUrl, onProgress)
}
/**
* Загрузить файл на конкретный URL.
*/
private suspend fun uploadFileToUrl(
uri: android.net.Uri,
mimeType: String,
fileName: String,
url: String
): String {
// Читаем файл в ByteArray
val fileBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw Exception("Cannot read file: $uri")
return uploadFileBytesToUrl(fileBytes, mimeType, fileName, url)
}
/**
* Загрузить ByteArray на указанный URL (поддерживает редиректы).
* Tinode требует RFC 2388 multipart/form-data.
*/
private suspend fun uploadFileBytesToUrl(
fileBytes: ByteArray,
mimeType: String,
fileName: String,
url: String
): String {
// RFC 2388 multipart/form-data — ожидаемый формат Tinode
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, fileBytes.toRequestBody(mimeType.toMediaType()))
.build()
val request = Request.Builder()
.url(url)
.post(multipartBody)
.build()
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(false)
.build()
.newCall(request).execute().use { response ->
return when (response.code) {
200 -> {
val body = response.body?.string() ?: ""
Timber.d("Upload response: $body")
val ctrl = gson.fromJson(body, ServerMessage::class.java).ctrl
val fileUrl = ctrl?.params?.url
?: throw Exception("No file URL in response. Response: $body")
normalizeFileUrl(fileUrl)
}
307, 302 -> {
val redirectUrl = response.header("Location")
?: throw Exception("No redirect URL in ${response.code} response")
Timber.d("Upload redirect ${response.code} to $redirectUrl")
uploadFileBytesToUrl(fileBytes, mimeType, fileName, redirectUrl)
}
else -> {
val errorBody = response.body?.string() ?: ""
Timber.e("Upload failed: ${response.code} ${response.message}, body: $errorBody")
throw Exception("Upload failed: ${response.code} ${response.message}. Server: $errorBody")
}
}
}
}
/**
* Загрузить файл с прогресс-колбэком.
*/
private suspend fun uploadFileToUrlWithProgress(
uri: android.net.Uri,
mimeType: String,
fileName: String,
url: String,
onProgress: (Float) -> Unit
): String {
// Читаем файл в ByteArray
val fileBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: return Result.failure<String>(Exception("Cannot open file")).getOrThrow()
// Строим multipart через OkHttp Builder (RFC 2388)
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, fileBytes.toRequestBody(mimeType.toMediaType()))
.build()
// Буферизуем multipart body для подсчёта размера и захватываем Content-Type с boundary
val buffer = okio.Buffer()
multipartBody.writeTo(buffer)
val bodyBytes = buffer.readByteArray()
val totalBytes = bodyBytes.size.toLong()
val contentType = multipartBody.contentType() // multipart/form-data; boundary=...
return uploadBytesWithProgress(bodyBytes, totalBytes, contentType, url, onProgress)
}
/**
* Отправить ByteArray с прогресс-колбэком (поддержка редиректов).
* Tinode требует RFC 2388 multipart/form-data.
*/
private suspend fun uploadBytesWithProgress(
bodyBytes: ByteArray,
totalBytes: Long,
contentType: okhttp3.MediaType?,
url: String,
onProgress: (Float) -> Unit
): String = suspendCoroutine { cont ->
val progressRequestBody = object : RequestBody() {
override fun contentType() = contentType
override fun contentLength() = totalBytes
override fun writeTo(sink: okio.BufferedSink) {
val chunkSize = 32768L // 32KB chunks
var offset = 0L
val total = bodyBytes.size.toLong()
while (offset < total) {
val toWrite = minOf(chunkSize, total - offset)
sink.write(bodyBytes, offset.toInt(), toWrite.toInt())
offset += toWrite
val progress = if (totalBytes > 0) offset.toFloat() / totalBytes else 0f
onProgress(progress.coerceIn(0f, 0.99f))
}
onProgress(1f)
}
}
val request = Request.Builder()
.url(url)
.post(progressRequestBody)
.build()
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.followRedirects(false)
.build()
.newCall(request).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
cont.resumeWith(Result.failure(Exception("Upload failed: ${e.message}", e)))
}
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
response.use { resp ->
try {
when (resp.code) {
200 -> {
val responseBody = resp.body?.string() ?: ""
Timber.d("Upload response: $responseBody")
val ctrl = gson.fromJson(responseBody, ServerMessage::class.java).ctrl
val fileUrl = ctrl?.params?.url
if (fileUrl != null) {
Timber.d("File URL received: $fileUrl")
cont.resumeWith(Result.success(normalizeFileUrl(fileUrl)))
} else {
Timber.e("No file URL in response! ctrl.params: ${ctrl?.params}")
cont.resumeWith(Result.failure(Exception("No file URL in response")))
}
}
301, 302, 307 -> {
val location = resp.header("Location")
?: throw Exception("No redirect URL for ${resp.code}")
val redirectUrl = resp.request.url.resolve(location)?.toString()
?: throw Exception("Failed to resolve redirect URL: $location")
Timber.d("Upload redirect ${resp.code}$redirectUrl")
// Рекурсивно отправляем те же байты на redirect URL
runBlocking {
try {
val result = uploadBytesWithProgress(
bodyBytes, totalBytes, contentType, redirectUrl, onProgress
)
cont.resumeWith(Result.success(result))
} catch (e: Exception) {
cont.resumeWith(Result.failure(e))
}
}
}
else -> {
val errorBody = resp.body?.string() ?: ""
Timber.e("Upload failed: ${resp.code} ${resp.message}, body: $errorBody")
cont.resumeWith(Result.failure(
Exception("Upload failed: ${resp.code} ${resp.message}. Server: $errorBody")
))
}
}
} catch (e: Exception) {
cont.resumeWith(Result.failure(e))
}
}
}
})
}
/**
* Нормализовать URL файла — относительный в путь /v0/file/s/...
*/
private fun normalizeFileUrl(url: String): String {
return when {
url.startsWith("/v0/file/s/") -> url
url.startsWith("./") -> "/v0/file/s/${url.substring(2)}"
url.startsWith("http") -> {
url.toHttpUrlOrNull()?.encodedPath ?: url
}
else -> "/v0/file/s/$url"
}
}
}
sealed class ConnectionEvent {
data object Connected : ConnectionEvent()
data object Disconnected : ConnectionEvent()
data object Authenticated : ConnectionEvent()
data class Error(val throwable: Throwable) : ConnectionEvent()
}

View File

@@ -0,0 +1,172 @@
package ru.lastochka.messenger.data.local
import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
import java.util.Date
// ─── Entities ────────────────────────────────────────────────────
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey
val seqId: Int,
val topicName: String,
val from: String,
val senderName: String,
val content: String, // Plain text from Drafty
val rawContent: String, // JSON Drafty
val timestamp: Long,
val isOwn: Boolean,
val isRead: Boolean,
val isEdited: Boolean,
val hasAttachment: Boolean,
val attachmentType: String?, // "image", "video", "file", "audio"
val attachmentUrl: String?,
val replyToSeq: Int? = null,
val replyToContent: String? = null
)
@Entity(tableName = "contacts")
data class ContactEntity(
@PrimaryKey
val topicName: String,
val displayName: String,
val avatar: String?,
val lastMessage: String?,
val lastMessageTime: Long,
val unread: Int,
val isGroup: Boolean,
val muted: Boolean,
val pinned: Boolean
)
@Entity(tableName = "typing_indicators")
data class TypingEntity(
@PrimaryKey
val topicName: String,
val who: String,
val timestamp: Long
)
// ─── DAOs ────────────────────────────────────────────────────────
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE topicName = :topicName ORDER BY timestamp ASC, seqId ASC")
fun getMessagesForTopic(topicName: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE topicName = :topicName ORDER BY timestamp ASC, seqId ASC LIMIT :limit OFFSET :offset")
suspend fun getMessagesPaged(topicName: String, limit: Int, offset: Int): List<MessageEntity>
@Query("SELECT MAX(seqId) FROM messages WHERE topicName = :topicName")
suspend fun getMaxSeq(topicName: String): Int?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
@Query("DELETE FROM messages WHERE seqId = :seqId")
suspend fun deleteMessageBySeqId(seqId: Int)
@Query("UPDATE messages SET content = :content, isEdited = :isEdited WHERE seqId = :seqId")
suspend fun updateMessageContent(seqId: Int, content: String, isEdited: Boolean)
@Query("DELETE FROM messages WHERE topicName = :topicName")
suspend fun clearTopicMessages(topicName: String)
@Query("UPDATE messages SET isRead = 1 WHERE topicName = :topicName AND isOwn = 0")
suspend fun markAllRead(topicName: String)
@Query("UPDATE messages SET attachmentUrl = :url, timestamp = :now WHERE seqId = :seqId")
suspend fun updateAttachmentUrl(seqId: Int, url: String, now: Long = System.currentTimeMillis())
@Query("UPDATE messages SET attachmentUrl = :url, attachmentType = :type, hasAttachment = 1, timestamp = :now WHERE seqId = :seqId")
suspend fun updateAttachmentFull(seqId: Int, url: String, type: String, now: Long = System.currentTimeMillis())
/** Обновить URL вложения в последней собственной записи чата (для echo с сервера) */
@Query("UPDATE messages SET attachmentUrl = :url WHERE topicName = :topic AND isOwn = 1 AND hasAttachment = 1 AND seqId < 0")
suspend fun updateLastOwnAttachmentUrl(topic: String, url: String)
}
@Dao
interface ContactDao {
@Query("SELECT * FROM contacts ORDER BY pinned DESC, lastMessageTime DESC")
fun getAllContacts(): Flow<List<ContactEntity>>
@Query("SELECT * FROM contacts WHERE topicName = :topicName")
suspend fun getContact(topicName: String): ContactEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertContacts(contacts: List<ContactEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertContact(contact: ContactEntity)
@Query("UPDATE contacts SET unread = 0 WHERE topicName = :topicName")
suspend fun clearUnread(topicName: String)
}
@Dao
interface TypingDao {
@Query("SELECT * FROM typing_indicators WHERE topicName = :topicName")
fun getTypingForTopic(topicName: String): Flow<List<TypingEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertTyping(typing: TypingEntity)
@Query("DELETE FROM typing_indicators WHERE topicName = :topicName AND who = :who")
suspend fun removeTyping(topicName: String, who: String)
@Query("DELETE FROM typing_indicators WHERE timestamp < :threshold")
suspend fun clearExpired(threshold: Long)
}
// ─── Migration V1 → V2 ─────────────────────────────────────────
/**
* Миграция: добавляем поля replyToSeq и replyToContent в messages.
* Эти поля нужны для функции "ответ на сообщение".
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE messages ADD COLUMN replyToSeq INTEGER")
database.execSQL("ALTER TABLE messages ADD COLUMN replyToContent TEXT")
}
}
// ─── Database ────────────────────────────────────────────────────
@Database(
entities = [MessageEntity::class, ContactEntity::class, TypingEntity::class],
version = 2,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun contactDao(): ContactDao
abstract fun typingDao(): TypingDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"lastochka_db"
)
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,368 @@
package ru.lastochka.messenger.data.model
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import java.lang.reflect.Type
import java.util.*
// Кастомный десериализатор для PubContent
// Сервер может слать content как строку ("привет") или объект ({"txt":"привет"} или Drafty {"txt":"","ent":[...]})
object PubContentDeserializer : JsonDeserializer<PubContent> {
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): PubContent {
return when {
json.isJsonObject -> {
val obj = json.asJsonObject
val txt = obj.get("txt")?.asString ?: ""
val ent = if (obj.has("ent")) {
context.deserialize<Array<DraftyEntity>>(obj.get("ent"), Array<DraftyEntity>::class.java).toList()
} else null
val fmt = if (obj.has("fmt")) {
context.deserialize<Array<DraftyFmt>>(obj.get("fmt"), Array<DraftyFmt>::class.java).toList()
} else null
PubContent(txt, ent, fmt)
}
json.isJsonPrimitive -> PubContent(json.asString)
else -> PubContent("")
}
}
}
// ─── Client Messages ─────────────────────────────────────────────
data class ClientMsgHi(
val hi: HiPacket
)
data class HiPacket(
val id: String,
@SerializedName("ver") val version: String = "0.25",
@SerializedName("ua") val userAgent: String = "lastochka-android/1.0"
)
data class ClientMsgAcc(
val acc: AccPacket
)
data class AccPacket(
val id: String,
val user: String? = null,
val scheme: String = "basic",
val secret: String,
val login: Boolean = false,
val desc: DescPacket? = null,
val tags: List<String>? = null,
val cred: List<Credential>? = null
)
data class Credential(
val meth: String, // "email", "tel"
val val_str: String? = null, // value - renamed to avoid Kotlin keyword
val done: Boolean = false
)
data class DescPacket(
val `public`: TheCard? = null,
val private: Any? = null
)
data class TheCard(
val fn: String? = null,
val photo: String? = null
)
data class ClientMsgLogin(
val login: LoginPacket
)
data class LoginPacket(
val id: String,
val scheme: String = "basic",
val secret: String
)
data class ClientMsgSub(
val sub: SubPacket
)
data class SubPacket(
val id: String,
val topic: String? = null,
val get: MetaGetPacket? = null,
val set: MetaSetPacket? = null
)
data class MetaGetPacket(
val desc: MetaGetDesc? = null,
val sub: MetaGetSub? = null,
val data: MetaGetData? = null,
val what: String? = null
)
data class MetaGetDesc(val ims: Long? = null)
data class MetaGetSub(val ims: Long? = null) {
companion object {
/** Запросить ВСЕ подписки (без ims ограничения) */
val All = MetaGetSub() // пустой объект {} при сериализации
}
}
data class MetaGetData(
val since: Int? = null,
val limit: Int? = 100
)
data class MetaSetPacket(
val desc: MetaSetDesc? = null,
val sub: MetaSetSub? = null
)
data class MetaSetDesc(
val tags: List<String>? = null,
val `public`: TheCard? = null,
val `private`: Any? = null
)
/**
* Приватные данные пользователя в me-топике.
*/
data class PrivateData(
val note: String? = null
)
data class MetaSetSub(val user: String? = null, val mode: String? = null)
data class ClientMsgPub(
val pub: PubPacket
)
data class PubPacket(
val id: String,
val topic: String,
val content: PubContent,
val head: PubHead? = null,
val extra: PubExtra? = null
)
data class PubContent(
val txt: String = "",
val ent: List<DraftyEntity>? = null,
val fmt: List<DraftyFmt>? = null
)
/** Drafty-формат для сообщений с вложениями */
data class PubContentDrafty(
val txt: String = "",
val ent: List<DraftyEntity>? = null,
val fmt: List<DraftyFmt>? = null
)
/** Вложение в Drafty */
data class DraftyEntity(
val tp: String = "EX", // Тип: EX = external
val `data`: DraftyData? = null
)
/** Данные вложения */
data class DraftyData(
val mime: String? = null, // "image/jpeg", "image/png" и т.д.
val name: String? = null, // имя файла
val ref: String? = null, // URL: "/v0/file/s/abc123.jpg"
@SerializedName("val") val val_str: String? = null, // inline base64 data (от web клиента)
val size: Long? = null, // размер в байтах
val width: Int? = null, // ширина изображения
val height: Int? = null // высота изображения
)
/** Форматирование в Drafty (ссылка на entity) */
data class DraftyFmt(
val at: Int, // позиция начала
val len: Int, // длина
val key: Int // индекс в ent
)
/** Extra с attachments (для garbage collection файлов) */
data class PubExtra(
val attachments: List<String> // ["/v0/file/s/abc123.jpg"]
)
data class PubHead(
val replaces: String? = null
)
data class ClientMsgNote(
val note: NotePacket
)
data class NotePacket(
val id: String,
val topic: String,
val what: String,
val seq: Int? = null
)
data class ClientMsgLeave(
val leave: LeavePacket
)
data class LeavePacket(
val id: String,
val topic: String,
val unsub: Boolean = false
)
data class ClientMsgGet(
val get: GetPacket
)
data class GetPacket(
val id: String,
val topic: String,
val desc: MetaGetDesc? = null,
val sub: MetaGetSub? = null,
val data: MetaGetData? = null,
val what: String? = null
)
data class ClientMsgSet(
val set: SetPacket
)
data class SetPacket(
val id: String,
val topic: String,
val desc: MetaSetDesc? = null,
val sub: MetaSetSub? = null
)
data class ClientMsgDel(
val del: DelPacket
)
data class DelPacket(
val id: String,
val topic: String,
val seq: DelSeq,
val hard: Boolean = false
)
data class DelSeq(
val first: Int,
val last: Int
)
// ─── Server Messages ─────────────────────────────────────────────
data class ServerMessage(
val ctrl: CtrlPacket? = null,
val data: DataPacket? = null,
val meta: MetaPacket? = null,
val pres: PresPacket? = null,
val info: InfoPacket? = null,
val del: DelPacket? = null
) {
val type: MsgType
get() = when {
ctrl != null -> MsgType.CTRL
data != null -> MsgType.DATA
meta != null -> MsgType.META
pres != null -> MsgType.PRES
info != null -> MsgType.INFO
del != null -> MsgType.DEL
else -> MsgType.UNKNOWN
}
}
enum class MsgType { CTRL, DATA, META, PRES, INFO, DEL, UNKNOWN }
data class CtrlPacket(
val id: String? = null,
val code: Int,
val text: String? = null,
val topic: String? = null,
val params: CtrlParams? = null
)
data class CtrlParams(
val user: String? = null,
val token: String? = null,
val recv: Int? = null,
val read: Int? = null,
val url: String? = null // URL загруженного файла
)
data class DataPacket(
val topic: String,
val from: String? = null,
val seq: Int,
val content: PubContent,
val head: PubHead? = null,
val extra: PubExtra? = null,
val ts: String? = null
)
data class MetaPacket(
val id: String? = null,
val topic: String? = null,
val desc: MetaDesc? = null,
val sub: List<MetaSub>? = null,
val tags: List<String>? = null,
val cred: List<Any>? = null,
val `public`: TheCard? = null
)
data class MetaDesc(
val created: String? = null,
val updated: String? = null,
val tags: List<String>? = null,
val cred: List<Any>? = null,
val acs: Acs? = null,
val `public`: TheCard? = null,
val `private`: Any? = null
)
data class MetaSub(
val user: String? = null,
val topic: String? = null,
val updated: String? = null,
val seq: Int = 0,
val read: Int = 0,
val recv: Int = 0,
val unread: Int = 0,
val acs: Acs? = null,
val `public`: TheCard? = null,
val private: Any? = null,
val lastSeen: LastSeen? = null
)
data class LastSeen(
val when_ts: String? = null,
val ua: String? = null,
val recv: Long? = null
)
data class Acs(
val want: String? = null,
val given: String? = null,
val mode: String? = null
)
data class PresPacket(
val topic: String,
val what: String,
val src: String? = null,
val seq: Int? = null,
val delp: DelMsg? = null
)
data class DelMsg(
val delId: Int? = null,
val first: Int? = null,
val last: Int? = null,
val all: Boolean = false
)
data class InfoPacket(
val topic: String,
val from: String,
val what: String,
val seq: Int? = null
)

View File

@@ -0,0 +1,71 @@
package ru.lastochka.messenger.di
import android.content.Context
import coil.ImageLoader
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.SessionRepository
import ru.lastochka.messenger.data.TinodeClient
import ru.lastochka.messenger.data.local.AppDatabase
import ru.lastochka.messenger.service.NetworkMonitor
import javax.inject.Singleton
/**
* Hilt модуль для предоставления зависимостей.
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return AppDatabase.getInstance(context)
}
@Provides
@Singleton
fun provideTinodeClient(@ApplicationContext context: Context): TinodeClient {
val app = context.applicationContext as ru.lastochka.messenger.LastochkaApp
return app.tinodeClient
}
@Provides
@Singleton
fun provideNetworkMonitor(@ApplicationContext context: Context): NetworkMonitor {
return NetworkMonitor(context)
}
@Provides
@Singleton
fun provideSessionRepository(
@ApplicationContext context: Context,
tinodeClient: TinodeClient,
networkMonitor: NetworkMonitor
): SessionRepository {
return SessionRepository(context, tinodeClient, networkMonitor)
}
@Provides
@Singleton
fun provideRepository(
tinodeClient: TinodeClient,
database: AppDatabase,
sessionRepository: SessionRepository
): ChatRepository {
return ChatRepository(tinodeClient, database, sessionRepository)
}
@Provides
@Singleton
fun provideImageLoader(
@ApplicationContext context: Context,
tinodeClient: TinodeClient
): ImageLoader {
return createImageLoader(context, tinodeClient)
}
}

View File

@@ -0,0 +1,80 @@
package ru.lastochka.messenger.di
import android.content.Context
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import ru.lastochka.messenger.data.TinodeClient
import timber.log.Timber
/**
* OkHttp Interceptor для добавления заголовков авторизации Tinode
* к запросам загрузки файлов.
*
* Официальный Tinode SDK использует заголовки:
* X-Tinode-APIKey: <apikey>
* X-Tinode-Auth: Token <token>
*
*而不是 query-параметры (?apikey=...&secret=...),
* которые сервер отклоняет с 400 malformed.
*/
class TinodeAuthInterceptor(
private val tinodeClient: TinodeClient
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val url = originalRequest.url.toString()
// Добавляем заголовки ТОЛЬКО для запросов к нашему серверу
if (url.contains(tinodeClient.serverHostName)) {
val authToken = tinodeClient.getAuthToken()
if (authToken != null) {
val newRequest = originalRequest.newBuilder()
.header("X-Tinode-APIKey", tinodeClient.getApiKey())
.header("X-Tinode-Auth", "Token $authToken")
.build()
Timber.d("TinodeAuthInterceptor: added auth headers for $url")
return chain.proceed(newRequest)
} else {
Timber.w("TinodeAuthInterceptor: no auth token available for $url")
}
}
return chain.proceed(originalRequest)
}
}
/**
* Создать ImageLoader для Coil с OkHttp клиентом,
* который автоматически добавляет заголовки авторизации Tinode.
*/
fun createImageLoader(
context: Context,
tinodeClient: TinodeClient
): ImageLoader {
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(TinodeAuthInterceptor(tinodeClient))
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.build()
return ImageLoader.Builder(context)
.okHttpClient(okHttpClient)
.memoryCache {
MemoryCache.Builder(context)
.maxSizePercent(0.25) // 25% доступной памяти
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("coil_image_cache"))
.maxSizeBytes(100L * 1024 * 1024) // 100 MB
.build()
}
.crossfade(true)
.build()
}

View File

@@ -0,0 +1,23 @@
package ru.lastochka.messenger.navigation
/**
* Экраны приложения для Compose Navigation.
*/
sealed class Screen(val route: String) {
data object Chats : Screen("chats")
data object Chat : Screen("chat/{topicName}/{topicTitle}") {
fun createRoute(topicName: String, topicTitle: String) = "chat/$topicName/$topicTitle"
}
data object NewChat : Screen("new_chat")
data object Calls : Screen("calls")
data object Settings : Screen("settings")
data object Profile : Screen("profile")
data object ContactInfo : Screen("contact_info/{topicName}/{topicTitle}") {
fun createRoute(topicName: String, topicTitle: String) = "contact_info/$topicName/$topicTitle"
}
data object CreateGroup : Screen("create_group")
data object Login : Screen("login")
data object Register : Screen("register")
}

View File

@@ -0,0 +1,102 @@
package ru.lastochka.messenger.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import ru.lastochka.messenger.MainActivity
import ru.lastochka.messenger.R
import ru.lastochka.messenger.data.SessionRepository
import ru.lastochka.messenger.data.TinodeHttpClient
import javax.inject.Inject
/**
* Сервис для обработки Push-уведомлений (FCM).
*/
@AndroidEntryPoint
class LastochkaFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var sessionRepository: SessionRepository
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
message.notification?.let { notification ->
val title = notification.title ?: "Ласточка"
val body = notification.body ?: "Новое сообщение"
// Данные сообщения (topicName и т.д.)
val topicName = message.data["topicName"]
val seq = message.data["seq"]?.toIntOrNull()
showNotification(title, body, topicName, seq)
}
}
override fun onNewToken(token: String) {
super.onNewToken(token)
// Отправляем новый токен на сервер Tinode при наличии сессии
serviceScope.launch {
try {
if (sessionRepository.isAuthenticated) {
// TODO: tinodeClient.setPushToken(token) — когда метод будет добавлен
android.util.Log.d("FCM", "New token: $token (will be sent to server)")
}
} catch (e: Exception) {
android.util.Log.e("FCM", "Failed to send push token", e)
}
}
}
private fun showNotification(title: String, body: String, topicName: String?, seq: Int?) {
val channelId = "lastochka_messages"
val notificationId = seq ?: System.currentTimeMillis().toInt() % 100000
val notificationManager = getSystemService(NotificationManager::class.java)
// Создаем канал (для Android 8.0+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Сообщения",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Уведомления о новых сообщениях"
}
notificationManager.createNotificationChannel(channel)
}
// Intent для открытия чата при клике
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (topicName != null) {
putExtra("topicName", topicName)
}
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.logo_splash) // Или иконка сообщения
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
notificationManager.notify(notificationId, notification)
}
}

View File

@@ -0,0 +1,73 @@
package ru.lastochka.messenger.service
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
/**
* Мониторинг состояния сети.
* При восстановлении подключения автоматически триггерит реконнект.
*/
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) {
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isConnected.value = true
}
override fun onLost(network: Network) {
_isConnected.value = false
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
// Сеть всё ещё доступна
_isConnected.value = true
}
}
/**
* Начать мониторинг сети.
* Вызывать при старте приложения.
*/
fun startMonitoring() {
// Проверка текущего состояния
val currentNetwork = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(currentNetwork)
_isConnected.value = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
// Регистрация callback
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
/**
* Остановить мониторинг.
* Вызывать при выходе из приложения.
*/
fun stopMonitoring() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (e: IllegalArgumentException) {
// Callback не зарегистрирован — игнорируем
}
}
}

View File

@@ -0,0 +1,127 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
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.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import ru.lastochka.messenger.ui.theme.AvatarColors
/**
* Аватар с инициалами и цветовым хешем (как в lastochka-ui).
*/
@Composable
fun Avatar(
name: String,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
avatarUrl: String? = null,
showOnlineIndicator: Boolean = false,
isOnline: Boolean = false
) {
Box(modifier = modifier.size(size)) {
// Аватар
if (!avatarUrl.isNullOrBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = name,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
val colorIndex = remember(name) {
(name.hashCode().and(0x7FFFFFFF)) % AvatarColors.size
}
val bgColor = remember(colorIndex) {
AvatarColors[colorIndex]
}
val initials = remember(name) {
name.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(bgColor),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = Color.White,
fontSize = (size.value * 0.38f).sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
}
// Индикатор онлайн
if (showOnlineIndicator) {
Box(
modifier = Modifier
.size((size.value * 0.28f).dp)
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(if (isOnline) Color(0xFF40C040) else Color(0xFFBBBB66))
.border(1.5.dp, MaterialTheme.colorScheme.surface, CircleShape)
)
}
}
}
/**
* Аватар поменьше (для списка чатов).
*/
@Composable
fun AvatarSmall(
name: String,
modifier: Modifier = Modifier,
avatarUrl: String? = null,
isOnline: Boolean = false
) {
Avatar(
name = name,
modifier = modifier,
size = 48.dp,
avatarUrl = avatarUrl,
showOnlineIndicator = true,
isOnline = isOnline
)
}
/**
* Аватар побольше (для хедера чата).
*/
@Composable
fun AvatarLarge(
name: String,
modifier: Modifier = Modifier,
avatarUrl: String? = null,
isOnline: Boolean = false
) {
Avatar(
name = name,
modifier = modifier,
size = 40.dp,
avatarUrl = avatarUrl,
showOnlineIndicator = true,
isOnline = isOnline
)
}

View File

@@ -0,0 +1,124 @@
package ru.lastochka.messenger.ui.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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Хедер чата (как в lastochka-ui ChatHeader).
*
* Показывает: аватар, имя, статус (онлайн/typing), кнопки действий.
*/
@Composable
fun ChatHeader(
name: String,
statusText: String?,
isOnline: Boolean,
avatarUrl: String? = null,
onBack: () -> Unit,
onCall: () -> Unit,
onMore: () -> Unit,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка назад
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Назад",
tint = MaterialTheme.colorScheme.onSurface
)
}
// Аватар + Имя (Кликабельная область)
Row(
modifier = Modifier
.weight(1f)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarLarge(
name = name,
avatarUrl = avatarUrl,
isOnline = isOnline
)
Column(
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (statusText != null) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (isOnline)
androidx.compose.ui.graphics.Color(0xFF40C040)
else
MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
// Кнопка звонка
IconButton(onClick = onCall) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Позвонить",
tint = MaterialTheme.colorScheme.primary
)
}
// Кнопка видеозвонка
IconButton(onClick = onCall) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = "Видеозвонок",
tint = MaterialTheme.colorScheme.primary
)
}
// Меню
IconButton(onClick = onMore) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Ещё",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
}

View File

@@ -0,0 +1,148 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.ui.theme.LocalBubbleColors
import ru.lastochka.messenger.ui.theme.ReadReceipt
import ru.lastochka.messenger.ui.theme.SentReceipt
import java.text.SimpleDateFormat
import java.util.*
/**
* Элемент чата в списке (как в lastochka-ui Sidebar → ChatItem).
*/
@Composable
fun ChatItem(
contact: ContactInfo,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
val dateFormat = SimpleDateFormat("dd.MM", Locale.getDefault())
val timeString = remember(contact.timestamp) {
contact.timestamp?.let { ts ->
val cal = Calendar.getInstance()
cal.time = ts
val now = Calendar.getInstance()
if (cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) &&
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR)) {
timeFormat.format(ts)
} else {
dateFormat.format(ts)
}
} ?: ""
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
onClick = onClick,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Аватар
AvatarSmall(
name = contact.displayName,
avatarUrl = contact.avatar,
isOnline = contact.isOnline && !contact.isGroup
)
Spacer(modifier = Modifier.width(12.dp))
// Контент
Column(modifier = Modifier.weight(1f)) {
// Имя + время
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = contact.displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (timeString.isNotEmpty()) {
Text(
text = timeString,
style = MaterialTheme.typography.labelSmall,
color = if (contact.unread > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Превью сообщения + бейдж непрочитанных
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = contact.lastMessage ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (contact.unread > 0) {
Spacer(modifier = Modifier.width(8.dp))
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
) {
Text(
text = if (contact.unread > 99) "99+" else contact.unread.toString(),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 2.dp)
)
}
}
if (contact.muted) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.NotificationsOff,
contentDescription = "Muted",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,311 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
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.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import timber.log.Timber
import ru.lastochka.messenger.LastochkaApp
import ru.lastochka.messenger.data.UiMessage
import ru.lastochka.messenger.ui.theme.LocalBubbleColors
import ru.lastochka.messenger.ui.theme.ReadReceipt
import ru.lastochka.messenger.ui.theme.SentReceipt
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: UiMessage,
modifier: Modifier = Modifier,
isFirstInGroup: Boolean = false,
isLastInGroup: Boolean = false,
showSender: Boolean = false,
onLongClick: (() -> Unit)? = null,
onSwipeReply: (() -> Unit)? = null,
onImageClick: ((String) -> Unit)? = null
) {
val bubbleColors = LocalBubbleColors.current
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
val bgColor = if (message.isOwn) bubbleColors.own else bubbleColors.peer
val textColor = if (message.isOwn) bubbleColors.ownText else bubbleColors.peerText
// State for swipe offset
var offsetX by remember { mutableFloatStateOf(0f) }
val maxOffset = 100f
val bubbleShape = when {
message.isOwn -> when {
isLastInGroup -> RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 4.dp)
else -> RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 4.dp, bottomEnd = 4.dp)
}
else -> when {
isLastInGroup -> RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 4.dp, bottomEnd = 18.dp)
else -> RoundedCornerShape(topStart = 4.dp, topEnd = 18.dp, bottomStart = 4.dp, bottomEnd = 4.dp)
}
}
val alignment = if (message.isOwn) Alignment.End else Alignment.Start
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = if (isFirstInGroup) 8.dp else 2.dp)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (offsetX > maxOffset / 2) {
onSwipeReply?.invoke()
}
offsetX = 0f
},
onHorizontalDrag = { _, dragAmount ->
if (dragAmount > 0) { // Only allow swipe right
offsetX = (offsetX + dragAmount).coerceIn(0f, maxOffset)
}
}
)
}
.combinedClickable(onClick = {}, onLongClick = onLongClick ?: {}),
horizontalAlignment = alignment
) {
if (showSender && !message.isOwn) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
)
}
Box(
modifier = Modifier
.clip(bubbleShape)
.background(bgColor)
.padding(horizontal = 12.dp, vertical = 8.dp)
.widthIn(max = 280.dp)
) {
Column {
// Reply Quote
if (message.replyToContent != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.background(Color(0xFFE0E0E0).copy(alpha = 0.3f), shape = RoundedCornerShape(4.dp))
.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
) {
Row {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(2.dp))
)
Spacer(modifier = Modifier.width(6.dp))
Column {
Text("Ответ", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
Text(message.replyToContent, style = MaterialTheme.typography.bodySmall, color = textColor.copy(alpha = 0.7f), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
// Image attachment
if (message.hasAttachment) {
val context = LocalContext.current
Timber.d("MessageBubble: hasAttachment=true, seqId=${message.seqId}, attachmentUrl=${message.attachmentUrl?.take(50)}")
if (message.attachmentUrl != null) {
// Determine if this is a data URL (base64 inline) or a server URL
val imageData = if (message.attachmentUrl.startsWith("data:")) {
// Inline base64 image from web client
message.attachmentUrl
} else {
// Server URL — build full download URL with auth headers
val app = context.applicationContext as LastochkaApp
app.tinodeClient.buildFileDownloadUrl(message.attachmentUrl)
}
Timber.d("MessageBubble: loading image, isBase64=${imageData.startsWith("data:")}, isOwn=${message.isOwn}")
SubcomposeAsyncImage(
model = ImageRequest.Builder(context)
.data(imageData)
.crossfade(true)
.listener(
onSuccess = { _, result ->
Timber.d("MessageBubble: image loaded successfully, size=${result.drawable?.intrinsicWidth}x${result.drawable?.intrinsicHeight}")
},
onError = { _, result ->
Timber.e("MessageBubble: image load failed: ${result.throwable?.message}")
}
)
.build(),
contentDescription = "Вложение",
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFCCCCCC))
.padding(bottom = 4.dp)
.clickable(enabled = onImageClick != null) {
onImageClick?.invoke(imageData)
},
contentScale = ContentScale.Fit,
loading = {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
},
error = {
val exc = it.result?.throwable
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.clickable { exc?.printStackTrace() },
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Ошибка загрузки",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
exc?.message?.let { msg ->
Text(
text = msg.take(50),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
)
} else {
// Placeholder: показываем иконку при отправке
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFE0E0E0))
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "📷",
fontSize = 32.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Отправка...",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
// Text content (если есть caption)
if (message.content.isNotBlank() && message.content != " ") {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge.copy(color = textColor),
modifier = Modifier.padding(end = 48.dp)
)
}
Row(
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
if (message.isEdited) {
Text(text = "ред.", style = MaterialTheme.typography.labelSmall, color = textColor.copy(alpha = 0.6f))
}
Text(
text = timeFormat.format(message.timestamp),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f)
)
if (message.isOwn) {
Icon(
imageVector = if (message.isRead) Icons.Filled.DoneAll else Icons.Filled.Done,
contentDescription = null,
tint = if (message.isRead) ReadReceipt else SentReceipt,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
/**
* Разделитель дат.
*/
@Composable
fun DateDivider(date: Date, modifier: Modifier = Modifier) {
val today = Calendar.getInstance()
val messageDate = Calendar.getInstance().apply { time = date }
val label = when {
today.get(Calendar.YEAR) == messageDate.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == messageDate.get(Calendar.DAY_OF_YEAR) -> "Сегодня"
today.get(Calendar.YEAR) == messageDate.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) - messageDate.get(Calendar.DAY_OF_YEAR) == 1 -> "Вчера"
else -> SimpleDateFormat("dd MMMM yyyy", Locale("ru")).format(date)
}
Box(
modifier = modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 6.dp)
)
}
}

View File

@@ -0,0 +1,126 @@
package ru.lastochka.messenger.ui.components
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.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
/**
* Поле ввода сообщения (как в lastochka-ui MessageInput).
*
* Стиль: скруглённое, светлый фон, иконка отправки справа.
*/
@Composable
fun MessageInput(
text: String,
onTextChanged: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Сообщение…",
replyToMessage: ru.lastochka.messenger.data.UiMessage? = null
) {
var isFocused by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка скрепки
IconButton(
onClick = onAttach,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Прикрепить",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Поле ввода
Surface(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp
) {
BasicTextField(
value = text,
onValueChange = onTextChanged,
textStyle = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.onFocusChanged { isFocused = it.isFocused },
decorationBox = { innerTextField ->
Box {
if (text.isEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
},
maxLines = 6
)
}
Spacer(modifier = Modifier.width(8.dp))
// Кнопка отправки / микрофон
IconButton(
onClick = {
if (text.isNotBlank()) {
onSend()
}
},
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (text.isNotBlank())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Icon(
imageVector = if (text.isNotBlank())
Icons.Default.Send
else
Icons.Default.Mic,
contentDescription = if (text.isNotBlank()) "Отправить" else "Голосовое",
tint = if (text.isNotBlank())
Color.White
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@@ -0,0 +1,222 @@
package ru.lastochka.messenger.ui.screens.auth
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.lastochka.messenger.R
import ru.lastochka.messenger.ui.theme.BrandPrimary
import ru.lastochka.messenger.viewmodel.AuthViewModel
import ru.lastochka.messenger.viewmodel.AuthUiState
/**
* Экран входа (как в lastochka-ui LoginScreen).
*/
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
// Автоматический переход при успехе
LaunchedEffect(uiState) {
if (uiState is AuthUiState.Success) {
onLoginSuccess()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(BrandPrimary),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Логотип
Image(
painter = painterResource(id = R.drawable.logo_splash),
contentDescription = "Ласточка",
modifier = Modifier
.size(120.dp)
.padding(bottom = 16.dp),
contentScale = ContentScale.Fit
)
// Заголовок
Text(
text = stringResource(R.string.login_title),
style = MaterialTheme.typography.displaySmall,
color = Color.White,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "Твой дом в интернете",
style = MaterialTheme.typography.bodyLarge,
color = Color.White.copy(alpha = 0.8f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 32.dp)
)
// Форма
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
// Username
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.login_username)) },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Password
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible)
Icons.Default.Visibility
else
Icons.Default.VisibilityOff,
contentDescription = null
)
}
},
visualTransformation = if (passwordVisible)
VisualTransformation.None
else
PasswordVisualTransformation(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.login(username, password)
}
),
modifier = Modifier.fillMaxWidth()
)
// Error
if (uiState is AuthUiState.Error) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = (uiState as AuthUiState.Error).message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(24.dp))
// Login button
Button(
onClick = { viewModel.login(username, password) },
enabled = uiState !is AuthUiState.Loading &&
username.isNotBlank() && password.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = BrandPrimary
)
) {
if (uiState is AuthUiState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White
)
} else {
Text(
text = stringResource(R.string.login_button),
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
}
}
// Register link
TextButton(
onClick = onNavigateToRegister,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text(
text = stringResource(R.string.login_no_account),
style = MaterialTheme.typography.bodyMedium,
color = BrandPrimary
)
}
}
}
}
}
}

View File

@@ -0,0 +1,580 @@
package ru.lastochka.messenger.ui.screens.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ru.lastochka.messenger.R
import ru.lastochka.messenger.ui.theme.BrandPrimary
import ru.lastochka.messenger.util.formatPhoneNumberWithCursor
import ru.lastochka.messenger.util.isValidEmail
import ru.lastochka.messenger.util.isValidPhoneNumber
import ru.lastochka.messenger.util.cleanPhoneNumber
import ru.lastochka.messenger.viewmodel.AuthViewModel
import ru.lastochka.messenger.viewmodel.AuthUiState
/**
* Экран регистрации (как в lastochka-ui RegisterForm).
* Поля: Логин, Email, Телефон, Ваше имя (опционально), Пароль, Подтверждение пароля
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
onRegisterSuccess: () -> Unit,
onNavigateToLogin: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val scope = rememberCoroutineScope()
// Данные регистрации
var login by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var phone by remember { mutableStateOf(TextFieldValue("")) }
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordConfirm by remember { mutableStateOf("") }
// Ошибки валидации
var loginError by remember { mutableStateOf("") }
var emailError by remember { mutableStateOf("") }
var phoneError by remember { mutableStateOf("") }
var passwordError by remember { mutableStateOf("") }
// Статусы проверки доступности
var isCheckingLogin by remember { mutableStateOf(false) }
var loginAvailable by remember { mutableStateOf<Boolean?>(null) }
var isCheckingEmail by remember { mutableStateOf(false) }
var emailAvailable by remember { mutableStateOf<Boolean?>(null) }
var isCheckingPhone by remember { mutableStateOf(false) }
var phoneAvailable by remember { mutableStateOf<Boolean?>(null) }
// Видимость паролей
var passwordVisible by remember { mutableStateOf(false) }
var passwordConfirmVisible by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
// Debounced проверка логина
LaunchedEffect(login) {
if (login.length >= 3) {
delay(500)
isCheckingLogin = true
viewModel.checkUsername(login) { available ->
loginAvailable = available
if (available) loginError = ""
else loginError = "Этот логин уже занят"
isCheckingLogin = false
}
} else {
loginAvailable = null
}
}
// Debounced проверка email
LaunchedEffect(email) {
val trimmed = email.trim()
if (isValidEmail(trimmed)) {
delay(500)
isCheckingEmail = true
viewModel.checkEmailAvailability(trimmed) { available ->
emailAvailable = available
if (available) emailError = ""
else emailError = "Этот email уже зарегистрирован"
isCheckingEmail = false
}
} else {
emailAvailable = null
}
}
// Debounced проверка телефона
LaunchedEffect(phone.text) {
val cleaned = cleanPhoneNumber(phone.text)
if (cleaned.length == 11 || phone.text.length >= 18) {
delay(500)
isCheckingPhone = true
viewModel.checkPhoneAvailability(phone.text) { available ->
phoneAvailable = available
if (available) phoneError = ""
else phoneError = "Этот номер уже зарегистрирован"
isCheckingPhone = false
}
} else {
phoneAvailable = null
}
}
LaunchedEffect(uiState) {
if (uiState is AuthUiState.Success) {
onRegisterSuccess()
}
}
// Валидация формы
fun validateForm(): Boolean {
var isValid = true
if (login.length < 3) {
loginError = "Логин должен быть не менее 3 символов"
isValid = false
} else if (!login.matches(Regex("^[a-zA-Z0-9_]+$"))) {
loginError = "Логин может содержать только буквы, цифры и подчёркивание"
isValid = false
} else if (loginAvailable == false) {
loginError = "Этот логин уже занят"
isValid = false
}
if (!isValidEmail(email)) {
emailError = "Введите корректный email"
isValid = false
} else if (emailAvailable == false) {
emailError = "Этот email уже зарегистрирован"
isValid = false
}
if (!isValidPhoneNumber(phone.text)) {
phoneError = "Введите корректный номер телефона"
isValid = false
} else if (phoneAvailable == false) {
phoneError = "Этот номер уже зарегистрирован"
isValid = false
}
if (password.length < 6) {
passwordError = "Пароль должен быть не менее 6 символов"
isValid = false
} else if (password != passwordConfirm) {
passwordError = "Пароли не совпадают"
isValid = false
}
return isValid
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.register_title)) },
navigationIcon = {
IconButton(onClick = onNavigateToLogin) {
Icon(Icons.Default.ArrowBack, "Назад")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Логин
OutlinedTextField(
value = login,
onValueChange = {
login = it.lowercase().filter { c -> c.isLetterOrDigit() || c == '_' }
loginError = ""
},
label = {
Text(
text = "${stringResource(R.string.register_username)} *",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.typography.bodyMedium.color
)
},
placeholder = { Text("username") },
leadingIcon = { Icon(Icons.Default.AccountCircle, null) },
trailingIcon = {
if (login.length >= 3) {
if (isCheckingLogin) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else if (loginAvailable == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF4CAF50)
)
}
}
},
singleLine = true,
isError = loginError.isNotEmpty(),
supportingText = {
when {
loginError.isNotEmpty() -> Text(loginError, color = MaterialTheme.colorScheme.error)
login.length >= 3 && loginAvailable == true ->
Text(stringResource(R.string.register_username_available), color = Color(0xFF4CAF50))
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Email
OutlinedTextField(
value = email,
onValueChange = {
email = it
emailError = ""
emailAvailable = null
},
label = {
Text(
text = "${stringResource(R.string.register_email)} *",
style = MaterialTheme.typography.bodyMedium
)
},
placeholder = { Text("example@mail.ru") },
leadingIcon = { Icon(Icons.Default.Email, null) },
trailingIcon = {
if (isValidEmail(email.trim())) {
if (isCheckingEmail) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else if (emailAvailable == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF4CAF50)
)
}
}
},
singleLine = true,
isError = emailError.isNotEmpty(),
supportingText = {
when {
emailError.isNotEmpty() -> Text(emailError, color = MaterialTheme.colorScheme.error)
isValidEmail(email.trim()) && emailAvailable == true ->
Text(stringResource(R.string.register_email_available), color = Color(0xFF4CAF50))
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Телефон
OutlinedTextField(
value = phone,
onValueChange = { newValue ->
val (formatted, newCursor) = formatPhoneNumberWithCursor(
newValue.text,
newValue.selection.start
)
phone = TextFieldValue(
text = formatted,
selection = TextRange(newCursor)
)
phoneError = ""
phoneAvailable = null
},
label = {
Text(
text = "${stringResource(R.string.register_phone)} *",
style = MaterialTheme.typography.bodyMedium
)
},
placeholder = { Text("+7 (999) 999-99-99") },
leadingIcon = { Icon(Icons.Default.Phone, null) },
trailingIcon = {
if (isValidPhoneNumber(phone.text)) {
if (isCheckingPhone) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else if (phoneAvailable == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF4CAF50)
)
}
}
},
singleLine = true,
isError = phoneError.isNotEmpty(),
supportingText = {
when {
phoneError.isNotEmpty() -> Text(phoneError, color = MaterialTheme.colorScheme.error)
isValidPhoneNumber(phone.text) && phoneAvailable == true ->
Text(stringResource(R.string.register_phone_available), color = Color(0xFF4CAF50))
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Отображаемое имя (опционально)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = {
Text(
text = "${stringResource(R.string.register_name)} ${stringResource(R.string.register_name_optional)}",
style = MaterialTheme.typography.bodyMedium
)
},
placeholder = { Text("Как к вам обращаться") },
leadingIcon = { Icon(Icons.Default.Person, null) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Будет отображаться в списке контактов. Если не указать, будет использоваться логин.",
style = MaterialTheme.typography.labelSmall,
color = Color.Gray,
modifier = Modifier.padding(start = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
// Пароль
OutlinedTextField(
value = password,
onValueChange = {
password = it
passwordError = ""
},
label = {
Text(
text = "${stringResource(R.string.register_password)} *",
style = MaterialTheme.typography.bodyMedium
)
},
placeholder = { Text("Минимум 6 символов") },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = null
)
}
},
visualTransformation = if (passwordVisible)
androidx.compose.ui.text.input.VisualTransformation.None
else
PasswordVisualTransformation(),
singleLine = true,
isError = passwordError.isNotEmpty(),
supportingText = {
if (passwordError.isNotEmpty()) {
Text(passwordError, color = MaterialTheme.colorScheme.error)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Подтверждение пароля
OutlinedTextField(
value = passwordConfirm,
onValueChange = { passwordConfirm = it },
label = {
Text(
text = "${stringResource(R.string.register_password_confirm)} *",
style = MaterialTheme.typography.bodyMedium
)
},
placeholder = { Text("Повторите пароль") },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { passwordConfirmVisible = !passwordConfirmVisible }) {
Icon(
imageVector = if (passwordConfirmVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = null
)
}
},
visualTransformation = if (passwordConfirmVisible)
androidx.compose.ui.text.input.VisualTransformation.None
else
PasswordVisualTransformation(),
singleLine = true,
isError = passwordConfirm.isNotEmpty() && password != passwordConfirm,
supportingText = {
when {
passwordError.isNotEmpty() -> Text(passwordError, color = MaterialTheme.colorScheme.error)
passwordConfirm.isNotEmpty() && password == passwordConfirm ->
Text("Пароли совпадают", color = Color(0xFF4CAF50))
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
scope.launch {
if (validateForm()) {
viewModel.registerWithFullProfile(
username = login,
password = password,
displayName = displayName.trim().ifBlank { login },
email = email.trim(),
phone = cleanPhoneNumber(phone.text)
)
}
}
}
),
modifier = Modifier.fillMaxWidth()
)
// Общая ошибка
if (uiState is AuthUiState.Error) {
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFEE2E2).copy(alpha = if (isSystemInDarkTheme()) 0.2f else 1f)
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = (uiState as AuthUiState.Error).message,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFDC2626),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Register button
Button(
onClick = {
scope.launch {
if (validateForm()) {
viewModel.registerWithFullProfile(
username = login,
password = password,
displayName = displayName.trim().ifBlank { login },
email = email.trim(),
phone = cleanPhoneNumber(phone.text)
)
}
}
},
enabled = uiState !is AuthUiState.Loading &&
!isCheckingLogin && !isCheckingEmail && !isCheckingPhone,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = BrandPrimary)
) {
if (uiState is AuthUiState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White)
} else {
Text(
text = stringResource(R.string.register_button),
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Terms text
Text(
text = stringResource(R.string.register_terms),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
// Login link
TextButton(
onClick = onNavigateToLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.register_have_account),
style = MaterialTheme.typography.bodyMedium,
color = BrandPrimary
)
}
}
}
}

View File

@@ -0,0 +1,48 @@
package ru.lastochka.messenger.ui.screens.calls
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Экран звонков (заглушка).
*/
@Composable
fun CallsScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = Color.Gray
)
Text(
text = "Звонки",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Text(
text = "Эта функция появится в следующей версии",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
}
}

View File

@@ -0,0 +1,713 @@
package ru.lastochka.messenger.ui.screens.chat
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.UiMessage
import ru.lastochka.messenger.ui.components.*
import ru.lastochka.messenger.viewmodel.ChatViewModel
import ru.lastochka.messenger.viewmodel.MessageActionType
import java.io.File
import java.util.*
/**
* Экран чата.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
topicName: String,
topicTitle: String,
onBack: () -> Unit,
onOpenContactInfo: () -> Unit,
viewModel: ChatViewModel = hiltViewModel()
) {
val messages by viewModel.messages.collectAsState()
val topicTitle by viewModel.topicTitle.collectAsState()
val isTyping by viewModel.isTyping.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val isSendingImage by viewModel.isSendingImage.collectAsState()
val imageUploadProgress by viewModel.imageUploadProgress.collectAsState()
// Action states
val selectedMessage by viewModel.selectedMessage.collectAsState()
val replyToMessage by viewModel.replyToMessage.collectAsState()
val editingMessage by viewModel.editingMessage.collectAsState()
var inputText by remember { mutableStateOf("") }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
// Fullscreen image viewer
var viewingImageUri by remember { mutableStateOf<String?>(null) }
// Launcher для выбора изображения из галереи
val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
selectedImageUri = uri
}
}
// Launcher для камеры — сохраняет фото в cache-директорию
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
var cameraImageFile by remember { mutableStateOf<File?>(null) }
val takePhotoLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success && cameraImageUri != null) {
selectedImageUri = cameraImageUri
}
cameraImageUri = null
cameraImageFile = null
}
// Launcher для запроса разрешения CAMERA
var pendingCameraAction by remember { mutableStateOf(false) }
val requestCameraPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted && pendingCameraAction) {
pendingCameraAction = false
doLaunchCamera(context, takePhotoLauncher) { uri, file ->
cameraImageUri = uri
cameraImageFile = file
}
} else if (!isGranted) {
Toast.makeText(
context,
"Разрешение на использование камеры отклонено. Выберите фото из галереи.",
Toast.LENGTH_LONG
).show()
}
}
fun launchCamera() {
when {
ContextCompat.checkSelfPermission(context, android.Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED -> {
doLaunchCamera(context, takePhotoLauncher) { uri, file ->
cameraImageUri = uri
cameraImageFile = file
}
}
else -> {
// Запрашиваем разрешение
pendingCameraAction = true
requestCameraPermission.launch(android.Manifest.permission.CAMERA)
}
}
}
// Debounce для typing indicator — не чаще 1 раза в 500ms
var lastTypingTime by remember { mutableLongStateOf(0L) }
fun onTextChanged(newText: String) {
inputText = newText
val now = System.currentTimeMillis()
if (now - lastTypingTime > 500 && newText.isNotBlank()) {
viewModel.sendTyping()
lastTypingTime = now
}
}
// Автоскролл
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size)
}
}
// Пагинация: загрузка при скролле к началу
LaunchedEffect(listState.firstVisibleItemIndex) {
if (listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset < 50) {
viewModel.loadMoreMessages()
}
}
// Показывать FAB "Вниз", если проскроллили вверх
val showScrollToBottom by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100
}
}
LaunchedEffect(Unit) {
viewModel.markAllRead()
}
// BottomSheet для действий с сообщениями
val sheetState = rememberModalBottomSheetState()
var showSheet by remember { mutableStateOf(false) }
// BottomSheet для выбора источника медиа (галерея / камера)
var showMediaSourceSheet by remember { mutableStateOf(false) }
LaunchedEffect(selectedMessage) {
if (selectedMessage != null) {
showSheet = true
} else {
showSheet = false
}
}
val currentSelectedMessage = selectedMessage
if (showSheet && currentSelectedMessage != null) {
ModalBottomSheet(
onDismissRequest = {
showSheet = false
viewModel.dismissActionMenu()
},
sheetState = sheetState
) {
Column(modifier = Modifier.padding(bottom = 24.dp)) {
SheetAction(Icons.Default.Reply, "Ответить") {
viewModel.executeAction(MessageActionType.REPLY)
showSheet = false
}
SheetAction(Icons.Default.ContentCopy, "Копировать") {
viewModel.executeAction(MessageActionType.COPY)
showSheet = false
}
if (currentSelectedMessage.isOwn) {
SheetAction(Icons.Default.Edit, "Редактировать") {
viewModel.executeAction(MessageActionType.EDIT)
showSheet = false
}
SheetAction(Icons.Default.Delete, "Удалить", color = Color(0xFFEF5350)) {
viewModel.executeAction(MessageActionType.DELETE)
showSheet = false
}
}
}
}
}
// Sheet выбора источника медиа
if (showMediaSourceSheet) {
ModalBottomSheet(
onDismissRequest = { showMediaSourceSheet = false },
sheetState = sheetState
) {
Column(modifier = Modifier.padding(bottom = 24.dp)) {
SheetAction(Icons.Default.PhotoLibrary, "Галерея") {
showMediaSourceSheet = false
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
SheetAction(Icons.Default.PhotoCamera, "Камера") {
showMediaSourceSheet = false
launchCamera()
}
}
}
}
Scaffold(
topBar = {
ChatHeader(
name = topicTitle,
statusText = if (isTyping) "печатает…" else "был(а) недавно",
isOnline = false,
onBack = onBack,
onCall = {},
onMore = {},
onClick = onOpenContactInfo
)
},
bottomBar = {
Column {
// Reply Preview
val currentReplyMsg = replyToMessage
AnimatedVisibility(visible = currentReplyMsg != null) {
val msg = currentReplyMsg ?: return@AnimatedVisibility
ReplyPreview(
message = msg,
onClose = { viewModel.clearReply() }
)
}
// Image Preview
val currentImageUri = selectedImageUri
AnimatedVisibility(visible = currentImageUri != null) {
val uri = currentImageUri ?: return@AnimatedVisibility
ImagePreviewBar(
imageUri = uri,
onClear = { selectedImageUri = null },
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
MessageInput(
text = inputText,
onTextChanged = ::onTextChanged,
onSend = {
val uri = selectedImageUri
if (uri != null) {
val mimeType = context.contentResolver.getType(uri)
val extension = mimeType?.substringAfter('/') ?: "jpg"
val fileName = "image_${System.currentTimeMillis()}.$extension"
viewModel.sendImageMessage(uri, mimeType ?: "image/jpeg", fileName, inputText)
selectedImageUri = null
inputText = ""
} else if (inputText.isNotBlank()) {
viewModel.sendMessage(inputText)
inputText = ""
}
},
onAttach = {
showMediaSourceSheet = true
},
replyToMessage = replyToMessage
)
}
},
floatingActionButton = {
// FAB "Вниз"
AnimatedVisibility(visible = showScrollToBottom) {
FloatingActionButton(
onClick = {
scope.launch {
listState.animateScrollToItem(messages.size)
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(bottom = 16.dp)
) {
Icon(Icons.Default.KeyboardArrowDown, "Вниз")
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE5DDD5)) // Telegram-like background color
.padding(padding)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (messages.isEmpty()) {
EmptyChatState()
} else {
ChatMessagesList(
messages = messages,
listState = listState,
onLongClick = { msg -> viewModel.onMessageLongClick(msg) },
onSwipeReply = { msg -> viewModel.replyToMessageExternally(msg) },
onImageClick = { uri -> viewingImageUri = uri }
)
}
// Progress overlay при отправке изображения
if (isSendingImage) {
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
LinearProgressIndicator(
progress = imageUploadProgress,
modifier = Modifier
.fillMaxWidth()
.height(4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Отправка изображения... ${(imageUploadProgress * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Fullscreen image viewer
val viewingUri = viewingImageUri
if (viewingUri != null) {
FullscreenImageViewer(
imageUrl = viewingUri,
onDismiss = { viewingImageUri = null }
)
}
}
}
@Composable
fun SheetAction(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, color: Color = LocalContentColor.current, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = label, tint = color)
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = color, fontWeight = FontWeight.Medium)
}
}
@Composable
fun ReplyPreview(message: UiMessage, onClose: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.width(4.dp)
.height(32.dp)
.background(MaterialTheme.colorScheme.primary, shape = MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Ответ на сообщение", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
Text(message.content, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, "Закрыть")
}
}
}
}
@Composable
fun ChatMessagesList(
messages: List<UiMessage>,
listState: androidx.compose.foundation.lazy.LazyListState,
onLongClick: (UiMessage) -> Unit,
onSwipeReply: (UiMessage) -> Unit,
onImageClick: (String) -> Unit
) {
val messagesWithDividers = remember(messages) {
buildList {
var lastDate: Date? = null
messages.forEach { msg ->
val msgDate = msg.timestamp.toDayStart()
if (msgDate != lastDate) {
add(UiMessageWrapper.Divider(msgDate))
lastDate = msgDate
}
add(UiMessageWrapper.Message(msg))
}
}
}
fun isLastInGroup(index: Int): Boolean {
val current = messagesWithDividers.getOrNull(index) as? UiMessageWrapper.Message ?: return true
val next = messagesWithDividers.getOrNull(index + 1)
return when (next) {
is UiMessageWrapper.Divider -> true
is UiMessageWrapper.Message -> next.message.isOwn != current.message.isOwn
null -> true
}
}
fun isFirstInGroup(index: Int): Boolean {
val current = messagesWithDividers.getOrNull(index) as? UiMessageWrapper.Message ?: return true
val prev = messagesWithDividers.getOrNull(index - 1)
return when (prev) {
is UiMessageWrapper.Divider -> true
is UiMessageWrapper.Message -> prev.message.isOwn != current.message.isOwn
null -> true
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(
items = messagesWithDividers,
key = { index, item ->
when (item) {
is UiMessageWrapper.Message -> "msg_${item.message.seqId}"
is UiMessageWrapper.Divider -> "div_${item.date.time}"
}
}
) { index, wrapper ->
when (wrapper) {
is UiMessageWrapper.Message -> {
MessageBubble(
message = wrapper.message,
isFirstInGroup = isFirstInGroup(index),
isLastInGroup = isLastInGroup(index),
onLongClick = { onLongClick(wrapper.message) },
onSwipeReply = { onSwipeReply(wrapper.message) },
onImageClick = onImageClick
)
}
is UiMessageWrapper.Divider -> {
DateDivider(date = wrapper.date)
}
}
}
}
}
@Composable
fun EmptyChatState() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "👋", fontSize = 48.sp, modifier = Modifier.padding(bottom = 16.dp))
Text(text = "Нет сообщений", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(text = "Начните диалог!", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// ─── Helpers ────────────────────────────────────────────────────
private fun Date.toDayStart(): Date {
val cal = Calendar.getInstance()
cal.time = this
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
return cal.time
}
sealed class UiMessageWrapper {
data class Message(val message: UiMessage) : UiMessageWrapper()
data class Divider(val date: Date) : UiMessageWrapper()
}
/**
* Превью выбранного изображения перед отправкой.
*/
@Composable
fun ImagePreviewBar(
imageUri: Uri,
onClear: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Thumbnail
AsyncImage(
model = imageUri,
contentDescription = "Выбранное изображение",
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(8.dp))
// File name hint
Text(
text = "Изображение",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
// Clear button
IconButton(onClick = onClear) {
Icon(Icons.Default.Close, "Удалить", tint = MaterialTheme.colorScheme.error)
}
}
}
}
/**
* Полноэкранный просмотр изображения с pinch-to-zoom.
*/
@Composable
fun FullscreenImageViewer(
imageUrl: String,
onDismiss: () -> Unit
) {
val context = LocalContext.current
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss)
) {
// Кнопка закрытия
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.5f), shape = MaterialTheme.shapes.small)
) {
Icon(Icons.Default.Close, "Закрыть", tint = Color.White)
}
// Изображение с pinch-to-zoom
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build(),
contentDescription = "Полноэкранный просмотр",
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
translationY = offsetY
}
.pointerInput(Unit) {
detectPinchZoom { centroid, pan, zoom ->
scale = (scale * zoom).coerceIn(1f, 5f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
} else {
offsetX = 0f
offsetY = 0f
}
}
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale > 1f) {
scale = 1f
offsetX = 0f
offsetY = 0f
} else {
scale = 3f
}
}
)
},
contentScale = ContentScale.Fit
)
}
}
/**
* Обнаружение pinch-to-zoom жестов.
*/
private suspend fun PointerInputScope.detectPinchZoom(
onGesture: (centroid: androidx.compose.ui.geometry.Offset, pan: androidx.compose.ui.geometry.Offset, zoom: Float) -> Unit
) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
}
awaitPointerEventScope {
var zoom = 1f
var pan = androidx.compose.ui.geometry.Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { it.isConsumed }
if (!canceled) {
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = (1f - zoom) * centroidSize.toFloat()
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop || panMotion > touchSlop) {
pastTouchSlop = true
lockedToPanZoom = true
}
}
if (pastTouchSlop) {
if (lockedToPanZoom) {
onGesture(event.calculateCentroid(useCurrent = false), panChange, zoomChange)
event.changes.forEach { change -> if (change.positionChanged()) change.consume() }
}
}
}
} while (!canceled && event.changes.any { it.pressed })
}
}
}
// ─── Camera helpers ─────────────────────────────────────────────
private fun doLaunchCamera(
context: Context,
takePhotoLauncher: androidx.activity.result.ActivityResultLauncher<Uri>,
onFileReady: (Uri, File) -> Unit
) {
val cacheDir = context.cacheDir
val photoFile = File.createTempFile("camera_", ".jpg", cacheDir)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile
)
onFileReady(uri, photoFile)
takePhotoLauncher.launch(uri)
}

View File

@@ -0,0 +1,163 @@
package ru.lastochka.messenger.ui.screens.chatlist
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.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.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
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.lastochka.messenger.R
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.ui.components.ChatItem
import ru.lastochka.messenger.viewmodel.ChatListViewModel
/**
* Список чатов (как в lastochka-ui Sidebar).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatListScreen(
onChatClick: (String, String) -> Unit,
onNewChat: () -> Unit,
onProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: ChatListViewModel = hiltViewModel()
) {
val contacts by viewModel.filteredContacts.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
// Handle session expired
LaunchedEffect(error) {
if (error == "SESSION_EXPIRED") {
viewModel.clearError()
onLogout()
}
}
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Чаты") },
actions = {
IconButton(onClick = onProfile) {
Icon(Icons.Default.Settings, "Настройки")
}
}
)
// Search Bar
OutlinedTextField(
value = searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
placeholder = { Text("Поиск") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.onSearchQueryChanged("") }) {
Icon(Icons.Default.ClearAll, "Очистить")
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = onNewChat,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.chat_new),
tint = Color.White
)
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(padding)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.primary
)
} else if (contacts.isEmpty()) {
// Пустое состояние
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "💬",
fontSize = 64.sp,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = stringResource(R.string.chats_empty),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.chats_empty_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = contacts,
key = { it.topicName }
) { contact ->
ChatItem(
contact = contact,
onClick = { onChatClick(contact.topicName, contact.displayName) }
)
}
}
}
// Error snackbar
if (error != null) {
Snackbar(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
containerColor = MaterialTheme.colorScheme.error,
contentColor = Color.White
) {
Text(error!!)
}
}
}
}
}

View File

@@ -0,0 +1,166 @@
package ru.lastochka.messenger.ui.screens.contact
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.CircleShape
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.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.lastochka.messenger.viewmodel.ContactInfoViewModel
/**
* Экран информации о контакте/чате.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactInfoScreen(
topicName: String,
topicTitle: String,
onBack: () -> Unit,
onNavigateToChat: () -> Unit,
viewModel: ContactInfoViewModel = hiltViewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Информация") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Назад")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(24.dp))
// Большой аватар
Surface(
modifier = Modifier.size(120.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = topicTitle.take(1).uppercase(),
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = topicTitle,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "был(а) недавно",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Секция: Действия
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column {
ActionItem(Icons.Default.Chat, "Написать") {
onNavigateToChat()
}
Divider()
ActionItem(Icons.Default.Call, "Позвонить", enabled = false)
Divider()
ActionItem(Icons.Default.VideoCall, "Видеозвонок", enabled = false)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Секция: Медиа (заглушка)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Медиа, ссылки и файлы", style = MaterialTheme.typography.titleMedium)
Icon(Icons.Default.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.height(12.dp))
// Placeholder grid
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(3) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.background(Color.Gray.copy(alpha = 0.2f), shape = MaterialTheme.shapes.small)
)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Кнопка "Удалить чат"
TextButton(
onClick = { /* TODO */ },
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFEF5350))
) {
Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Удалить чат", fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
fun ActionItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, enabled: Boolean = true, onClick: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled && onClick != null, onClick = onClick ?: {})
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = label, tint = if (enabled) MaterialTheme.colorScheme.primary else Color.Gray)
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = if (enabled) MaterialTheme.colorScheme.onSurface else Color.Gray)
}
}

View File

@@ -0,0 +1,166 @@
package ru.lastochka.messenger.ui.screens.groups
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.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.ui.components.AvatarSmall
import ru.lastochka.messenger.viewmodel.CreateGroupViewModel
/**
* Экран создания группы.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateGroupScreen(
onGroupCreated: (String) -> Unit,
onBack: () -> Unit,
viewModel: CreateGroupViewModel = hiltViewModel()
) {
val groupName by viewModel.groupName.collectAsState()
val description by viewModel.description.collectAsState()
val contacts by viewModel.contacts.collectAsState()
val selectedMembers by viewModel.selectedMembers.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Новая группа") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Назад")
}
},
actions = {
TextButton(
onClick = {
viewModel.createGroup { topicName ->
onGroupCreated(topicName)
}
},
enabled = groupName.isNotBlank() && !isLoading
) {
Text("Создать", fontWeight = FontWeight.Bold)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Group Name Input
OutlinedTextField(
value = groupName,
onValueChange = viewModel::updateGroupName,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
label = { Text("Название группы") },
placeholder = { Text("Введите название") },
leadingIcon = { Icon(Icons.Default.Group, null) },
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
)
// Description Input
OutlinedTextField(
value = description,
onValueChange = viewModel::updateDescription,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
label = { Text("Описание (необязательно)") },
placeholder = { Text("О чем эта группа?") },
leadingIcon = { Icon(Icons.Default.Info, null) },
minLines = 2,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
Spacer(modifier = Modifier.height(16.dp))
Divider()
// Members Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Участники", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (selectedMembers.isNotEmpty()) {
Text("${selectedMembers.size} выбрано", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
}
}
// Members List
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(contacts, key = { it.topicName }) { contact ->
MemberItem(
contact = contact,
isSelected = selectedMembers.contains(contact),
onClick = { viewModel.toggleMember(contact) }
)
}
}
}
}
}
@Composable
fun MemberItem(contact: ContactInfo, isSelected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarSmall(name = contact.displayName, avatarUrl = contact.avatar)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = contact.displayName,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (isSelected) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
} else {
Icon(
imageVector = Icons.Default.RadioButtonUnchecked,
contentDescription = "Unselected",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
}
}

View File

@@ -0,0 +1,256 @@
package ru.lastochka.messenger.ui.screens.newchat
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.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
import ru.lastochka.messenger.R
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.ui.components.AvatarSmall
import ru.lastochka.messenger.viewmodel.NewChatViewModel
/**
* Экран поиска пользователей и создания групп.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewChatScreen(
onChatSelected: (ContactInfo) -> Unit,
onCreateGroup: () -> Unit,
onCreateChannel: () -> Unit,
onBack: () -> Unit,
viewModel: NewChatViewModel = hiltViewModel()
) {
val searchResults by viewModel.searchResults.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var query by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Новый чат") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Назад")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Actions Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onCreateGroup,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Group, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Группа")
}
Button(
onClick = onCreateChannel,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.VolumeUp, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Канал")
}
}
// Search Header
Text(
text = "Искать пользователей",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
// Поле поиска
OutlinedTextField(
value = query,
onValueChange = {
query = it
viewModel.search(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
label = { Text("Поиск по имени или логину") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = {
query = ""
viewModel.clearResults()
}) {
Icon(Icons.Default.Clear, "Очистить")
}
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { focusManager.clearFocus() }
),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
// Результаты поиска
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (searchResults.isEmpty() && query.length >= 2) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.PersonSearch,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Ничего не найдено",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray
)
}
}
} else if (searchResults.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Введите имя для поиска",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = searchResults,
key = { it.topicName }
) { contact ->
SearchUserItem(
contact = contact,
onClick = {
scope.launch {
val result = viewModel.startChat(contact.topicName)
if (result.isSuccess) {
onChatSelected(contact)
}
}
}
)
}
}
}
}
}
}
/**
* Элемент результата поиска.
*/
@Composable
fun SearchUserItem(
contact: ContactInfo,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarSmall(
name = contact.displayName,
avatarUrl = contact.avatar
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = contact.displayName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Нажмите чтобы начать чат",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
}
}

View File

@@ -0,0 +1,125 @@
package ru.lastochka.messenger.ui.screens.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
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.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
import ru.lastochka.messenger.viewmodel.ProfileViewModel
/**
* Экран редактирования профиля.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel()
) {
val name by viewModel.name.collectAsState()
val bio by viewModel.bio.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Профиль") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Назад")
}
},
actions = {
IconButton(
onClick = {
scope.launch {
viewModel.saveProfile()
onNavigateBack()
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Icon(Icons.Default.Check, "Сохранить")
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Аватар
Surface(
modifier = Modifier.size(100.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = name.take(1).uppercase(),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Имя
OutlinedTextField(
value = name,
onValueChange = viewModel::updateName,
label = { Text("Имя") },
placeholder = { Text("Ваше имя") },
leadingIcon = { Icon(Icons.Default.Person, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
)
Spacer(modifier = Modifier.height(16.dp))
// Био
OutlinedTextField(
value = bio,
onValueChange = viewModel::updateBio,
label = { Text("О себе") },
placeholder = { Text("Расскажите о себе") },
leadingIcon = { Icon(Icons.Default.Info, null) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Ваш профиль виден другим пользователям",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -0,0 +1,213 @@
package ru.lastochka.messenger.ui.screens.settings
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.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Экран настроек.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateToProfile: () -> Unit,
onLogout: () -> Unit
) {
var showLogoutDialog by remember { mutableStateOf(false) }
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Выход") },
text = { Text("Вы действительно хотите выйти из аккаунта?") },
confirmButton = {
TextButton(
onClick = {
showLogoutDialog = false
// onLogout → MainActivity → sessionRepository.logout() → authState → Unauthenticated
onLogout()
}
) {
Text("Выйти", color = Color(0xFFEF5350), fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Отмена")
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Настройки") }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
LazyColumn {
// Профиль
item {
SettingsSectionHeader("Профиль")
}
item {
SettingsItem(
icon = Icons.Default.Person,
title = "Изменить профиль",
subtitle = "Имя, фото, био",
onClick = onNavigateToProfile
)
}
// Уведомления
item {
SettingsSectionHeader("Уведомления")
}
item {
var notificationsEnabled by remember { mutableStateOf(true) }
SettingsItem(
icon = Icons.Default.Notifications,
title = "Уведомления",
subtitle = "Звук и вибрация",
trailing = {
Switch(
checked = notificationsEnabled,
onCheckedChange = { notificationsEnabled = it }
)
},
onClick = { notificationsEnabled = !notificationsEnabled }
)
}
// Внешний вид
item {
SettingsSectionHeader("Внешний вид")
}
item {
var isDarkTheme by remember { mutableStateOf(false) }
SettingsItem(
icon = Icons.Default.DarkMode,
title = "Тёмная тема",
trailing = {
Switch(
checked = isDarkTheme,
onCheckedChange = { isDarkTheme = it }
)
},
onClick = { isDarkTheme = !isDarkTheme }
)
}
// О приложении
item {
SettingsSectionHeader("О приложении")
}
item {
SettingsItem(
icon = Icons.Default.Info,
title = "Версия",
subtitle = "1.0.0 (Alpha)"
)
}
item {
SettingsItem(
icon = Icons.Default.Code,
title = "Лицензия",
subtitle = "GPL v3"
)
}
// Выход
item {
Spacer(modifier = Modifier.height(16.dp))
}
item {
Button(
onClick = { showLogoutDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFEF5350)
)
) {
Icon(Icons.Default.Logout, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Выйти", fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
}
@Composable
fun SettingsSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
fun SettingsItem(
icon: ImageVector,
title: String,
subtitle: String? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}

View File

@@ -0,0 +1,54 @@
package ru.lastochka.messenger.ui.theme
import androidx.compose.ui.graphics.Color
// ─── Brand colors (from lastochka-ui) ────────────────────────────
val BrandPrimary = Color(0xFF5B5EF4) // индиго
val BrandPrimaryDark = Color(0xFF4338CA)
val BrandSecondary = Color(0xFF7B61FF) // фиолетовый
val BrandSecondaryDark = Color(0xFF5C45D6)
val BrandAccent = Color(0xFFFFD93D) // солнечный
// ─── Light theme colors ─────────────────────────────────────────
val Background = Color(0xFFEFEFF3)
val Surface = Color(0xFFFFFFFF)
val SurfaceVariant = Color(0xFFF5F5F5)
val OnSurface = Color(0xFF1A1A1A)
val OnSurfaceVariant = Color(0xFF666666)
val Outline = Color(0xFFE0E0E0)
// ─── Message bubbles (light) ────────────────────────────────────
val BubbleOwn = Color(0xFFEEF2FF) // индиго-светлый (свои)
val BubblePeer = Color(0xFFFFFFFF) // белый (чужие)
val BubbleOwnText = Color(0xFF1A1A1A)
val BubblePeerText = Color(0xFF1A1A1A)
// ─── Dark theme colors ──────────────────────────────────────────
val BackgroundDark = Color(0xFF0E1621)
val SurfaceDark = Color(0xFF17212B)
val SurfaceVariantDark = Color(0xFF1E2C3A)
val OnSurfaceDark = Color(0xFFF5F5F5)
val OnSurfaceVariantDark = Color(0xFF9E9E9E)
val OutlineDark = Color(0xFF2B3A4A)
// ─── Message bubbles (dark) ─────────────────────────────────────
val BubbleOwnDark = Color(0xFF2B5278)
val BubblePeerDark = Color(0xFF182533)
val BubbleOwnTextDark = Color(0xFFE8E8E8)
val BubblePeerTextDark = Color(0xFFE8E8E8)
// ─── Status colors ──────────────────────────────────────────────
val Online = Color(0xFF40C040)
val ReadReceipt = BrandPrimary
val SentReceipt = Color(0xFF9E9E9E)
val SelectionOverlay = Color(0x2F3F51B5)
// ─── Avatar palette ─────────────────────────────────────────────
val AvatarColors = listOf(
Color(0xFF5B5EF4), Color(0xFF7B61FF), Color(0xFF2E86DE),
Color(0xFF3BAFFF), Color(0xFF4CAF50), Color(0xFFFFD93D),
Color(0xFFFF6B6B), Color(0xFFFF9F43), Color(0xFFA29BFE),
Color(0xFFFD79A8), Color(0xFF00CEC9), Color(0xFFE17055),
Color(0xFF6C5CE7), Color(0xFF00B894), Color(0xFFFDCB6E),
Color(0xFFE84393)
)

View File

@@ -0,0 +1,91 @@
package ru.lastochka.messenger.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ─── Lastochka ColorScheme ───────────────────────────────────────
private val LightColorScheme = lightColorScheme(
primary = BrandPrimary,
onPrimary = Color.White,
primaryContainer = BubbleOwn,
onPrimaryContainer = BubbleOwnText,
secondary = BrandSecondary,
onSecondary = Color.White,
surface = Surface,
onSurface = OnSurface,
surfaceVariant = SurfaceVariant,
onSurfaceVariant = OnSurfaceVariant,
background = Background,
onBackground = OnSurface,
outline = Outline,
outlineVariant = Outline,
error = Color(0xFFB71C1C)
)
private val DarkColorScheme = darkColorScheme(
primary = BrandPrimary,
onPrimary = Color.White,
primaryContainer = BubbleOwnDark,
onPrimaryContainer = BubbleOwnTextDark,
secondary = BrandSecondary,
onSecondary = Color.White,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
background = BackgroundDark,
onBackground = OnSurfaceDark,
outline = OutlineDark,
outlineVariant = OutlineDark,
error = Color(0xFFCF6679)
)
// ─── Bubble colors (локально для чата) ───────────────────────────
data class BubbleColors(
val own: Color,
val ownText: Color,
val peer: Color,
val peerText: Color
)
private val LightBubbleColors = BubbleColors(
own = BubbleOwn,
ownText = BubbleOwnText,
peer = BubblePeer,
peerText = BubblePeerText
)
private val DarkBubbleColors = BubbleColors(
own = BubbleOwnDark,
ownText = BubbleOwnTextDark,
peer = BubblePeerDark,
peerText = BubblePeerTextDark
)
val LocalBubbleColors = staticCompositionLocalOf { LightBubbleColors }
// ─── Theme ───────────────────────────────────────────────────────
@Composable
fun LastochkaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val bubbleColors = if (darkTheme) DarkBubbleColors else LightBubbleColors
MaterialTheme(
colorScheme = colorScheme,
typography = Typography
) {
CompositionLocalProvider(LocalBubbleColors provides bubbleColors) {
content()
}
}
}

View File

@@ -0,0 +1,101 @@
package ru.lastochka.messenger.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// ─── Типографика в стиле lastochka-ui ────────────────────────────
val Typography = Typography(
// Заголовки
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
// Заголовок чата
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.sp
),
// Имя в списке чатов
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
// Текст сообщения
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 22.sp,
letterSpacing = 0.15.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
// Мета-данные (время, статус)
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,225 @@
package ru.lastochka.messenger.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
/**
* Утилита для сжатия изображений перед отправкой.
*
* Стратегия:
* - Максимальная сторона: 1920px (Full HD) — достаточно для просмотра на любом экране
* - Качество JPEG: 85% — хороший баланс качество/размер
* - Если изображение < 1MB — не сжимаем
* - Сохраняем ориентацию (EXIF)
*/
object ImageCompressor {
private const val MAX_SIDE = 1920
private const val JPEG_QUALITY = 85
private const val NO_COMPRESS_THRESHOLD = 1_000_000L // 1MB
/**
* Сжать изображение из Uri. Возвращает временный файл со сжатым JPEG.
*/
fun compressImage(context: Context, uri: Uri): CompressedImage {
val inputStream = context.contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("Cannot open URI: $uri")
// Сначала читаем размеры без декодирования
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(inputStream, null, options)
inputStream.close()
val originalWidth = options.outWidth
val originalHeight = options.outHeight
val originalSize = context.contentResolver.openInputStream(uri)?.use { it.available().toLong() } ?: 0L
// Если маленькое — не сжимаем
if (originalSize < NO_COMPRESS_THRESHOLD && originalSize > 0) {
return CompressedImage(
file = uriToFile(context, uri),
mimeType = context.contentResolver.getType(uri) ?: "image/jpeg",
originalSize = originalSize,
compressedSize = originalSize,
wasCompressed = false
)
}
// Вычисляем inSampleSize для декодирования
val targetSize = calculateTargetSize(originalWidth, originalHeight)
val inSampleSize = calculateInSampleSize(options, targetSize.width, targetSize.height)
// Декодируем с уменьшением
val decodeOptions = BitmapFactory.Options().apply {
this.inSampleSize = inSampleSize
this.inPreferredConfig = Bitmap.Config.RGB_565 // Экономим память
}
val decodeStream = context.contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(decodeStream, null, decodeOptions)
?: throw IllegalStateException("Failed to decode bitmap")
decodeStream?.close()
// Проверяем ориентацию (EXIF)
val orientation = getOrientation(context, uri)
// Поворачиваем если нужно
val rotatedBitmap = rotateBitmapIfNeeded(bitmap, orientation)
if (rotatedBitmap !== bitmap) {
bitmap.recycle()
}
// Если после downsampling всё ещё больше MAX_SIDE — дожимаем
val rotatedWidth = rotatedBitmap.width
val rotatedHeight = rotatedBitmap.height
val finalBitmap = if (rotatedWidth > MAX_SIDE || rotatedHeight > MAX_SIDE) {
val scale = MAX_SIDE.toFloat() / maxOf(rotatedWidth, rotatedHeight)
val scaledWidth = (rotatedWidth * scale).toInt()
val scaledHeight = (rotatedHeight * scale).toInt()
val scaled = Bitmap.createScaledBitmap(rotatedBitmap, scaledWidth, scaledHeight, true)
if (scaled !== rotatedBitmap) rotatedBitmap.recycle()
scaled
} else {
rotatedBitmap
}
// Сохраняем в JPEG
val compressedFile = File.createTempFile("compressed_", ".jpg", context.cacheDir)
val outputStream = FileOutputStream(compressedFile)
finalBitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, outputStream)
outputStream.flush()
outputStream.close()
finalBitmap.recycle()
val compressedSize = compressedFile.length()
return CompressedImage(
file = compressedFile,
mimeType = "image/jpeg",
originalSize = originalSize,
compressedSize = compressedSize,
wasCompressed = true
)
}
/**
* Сжать изображение из File (для камеры).
*/
fun compressImageFile(context: Context, file: File): CompressedImage {
val uri = android.net.Uri.fromFile(file)
return compressImage(context, uri)
}
private fun calculateTargetSize(width: Int, height: Int): Size {
val maxSide = MAX_SIDE
return if (width <= maxSide && height <= maxSide) {
Size(width, height)
} else {
val ratio = if (width > height) maxSide.toFloat() / width else maxSide.toFloat() / height
Size((width * ratio).toInt(), (height * ratio).toInt())
}
}
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height, width) = Pair(options.outHeight, options.outWidth)
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
private fun getOrientation(context: Context, uri: Uri): Int {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
val exif = ExifInterface(input)
exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (e: Exception) {
ExifInterface.ORIENTATION_NORMAL
}
}
private fun rotateBitmapIfNeeded(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = android.graphics.Matrix()
var needsRotation = false
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
needsRotation = true
}
ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
needsRotation = true
}
ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
needsRotation = true
}
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
needsRotation = true
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
needsRotation = true
}
else -> return bitmap
}
return if (needsRotation) {
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
} else {
bitmap
}
}
private fun uriToFile(context: Context, uri: Uri): File {
// Для file:// URI — используем путь напрямую
if (uri.scheme == "file") {
val path = uri.path
?: throw IllegalArgumentException("URI has null path: $uri")
return File(path)
}
val tempFile = File.createTempFile("original_", ".tmp", context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
}
return tempFile
}
private fun maxOf(a: Int, b: Int) = if (a > b) a else b
}
/**
* Результат сжатия изображения.
*/
data class CompressedImage(
val file: File,
val mimeType: String,
val originalSize: Long,
val compressedSize: Long,
val wasCompressed: Boolean
) {
val compressionRatio: Float
get() = if (originalSize > 0) compressedSize.toFloat() / originalSize else 1f
val compressionPercent: Int
get() = ((1f - compressionRatio) * 100).toInt()
}

View File

@@ -0,0 +1,97 @@
package ru.lastochka.messenger.util
/**
* Форматирование телефона в формат +7 (XXX) XXX-XX-XX
* Возвращает Pair(formattedText, newCursorPos) для корректной работы курсора
*/
fun formatPhoneNumberWithCursor(text: String, cursorPosition: Int): Pair<String, Int> {
// Извлекаем только цифры из всего текста
val fullDigits = text.filter { it.isDigit() }
// Если начинается с 8, заменяем на 7
val cleaned = if (fullDigits.startsWith("8") && fullDigits.length >= 1) {
"7" + fullDigits.substring(1)
} else {
fullDigits
}
// Ограничиваем 11 цифрами (7 + 10)
val digits = if (cleaned.length > 11) cleaned.substring(0, 11) else cleaned
// Извлекаем только цифры до позиции курсора в исходном тексте
val beforeCursor = text.substring(0, cursorPosition.coerceAtMost(text.length)).filter { it.isDigit() }
val digitsBeforeCursor = beforeCursor.length.coerceAtMost(digits.length)
// Строим форматированную строку +7 (XXX) XXX-XX-XX
val formatted = buildString {
if (digits.isEmpty()) return@buildString
append("+7")
if (digits.length > 1) {
append(" (")
val end = 1 + (digits.length - 1).coerceAtMost(3)
append(digits.substring(1, end))
}
if (digits.length > 4) {
append(") ")
val end = 4 + (digits.length - 4).coerceAtMost(3)
append(digits.substring(4, end))
}
if (digits.length > 7) {
append("-")
val end = 7 + (digits.length - 7).coerceAtMost(2)
append(digits.substring(7, end))
}
if (digits.length > 9) {
append("-")
val end = 9 + (digits.length - 9).coerceAtMost(2)
append(digits.substring(9, end))
}
}
// Вычисляем позицию курсора в отформатированной строке
val newCursorPos = when {
digitsBeforeCursor == 0 -> 0
digitsBeforeCursor == 1 -> 2 // после "+7"
digitsBeforeCursor <= 4 -> 4 + (digitsBeforeCursor - 1) // внутри "(XXX"
digitsBeforeCursor <= 7 -> 9 + (digitsBeforeCursor - 4) // внутри " XXX"
digitsBeforeCursor <= 9 -> 13 + (digitsBeforeCursor - 7) // внутри "-XX"
else -> 16 + (digitsBeforeCursor - 9) // внутри "-XX"
}
// Не даём курсору выйти за пределы строки
val clampedCursor = newCursorPos.coerceAtMost(formatted.length)
return Pair(formatted, clampedCursor)
}
/**
* Простое форматирование (без управления курсором)
*/
fun formatPhoneNumber(input: String): String {
return formatPhoneNumberWithCursor(input, input.length).first
}
/**
* Проверка валидности email
*/
fun isValidEmail(email: String): Boolean {
if (email.isBlank()) return false
val pattern = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return pattern.matches(email.trim())
}
/**
* Проверка валидности телефона (11 цифр, начинается с 7)
*/
fun isValidPhoneNumber(phone: String): Boolean {
val cleaned = phone.filter { it.isDigit() }
return cleaned.length == 11 && cleaned.startsWith("7")
}
/**
* Очистка номера телефона (только цифры)
*/
fun cleanPhoneNumber(phone: String): String {
return phone.filter { it.isDigit() }
}

View File

@@ -0,0 +1,111 @@
package ru.lastochka.messenger.util
import kotlinx.coroutines.delay
import kotlin.math.min
import kotlin.math.pow
/**
* Утилита для выполнения операций с retry логикой и exponential backoff.
*
* Пример использования:
* ```
* val result = retryWithBackoff(
* maxRetries = 3,
* initialDelayMs = 1000L,
* maxDelayMs = 10000L,
* backoffFactor = 2.0
* ) {
* tinodeClient.login(username, password)
* }
* ```
*
* @param maxRetries Максимальное количество попыток (не считая первую)
* @param initialDelayMs Начальная задержка в мс (по умолчанию 1 секунда)
* @param maxDelayMs Максимальная задержка в мс (по умолчанию 30 секунд)
* @param backoffFactor Множитель для экспоненциального роста (по умолчанию 2.0)
* @param shouldRetry Предикат для определения стоит ли повторять попытку
* @param block Выполняемая операция
* @return Результат операции или последнюю ошибку
*/
suspend fun <T> retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 1_000L,
maxDelayMs: Long = 30_000L,
backoffFactor: Double = 2.0,
shouldRetry: (Throwable) -> Boolean = { true },
block: suspend () -> T
): Result<T> {
var lastError: Throwable? = null
repeat(maxRetries + 1) { attempt ->
try {
val result = block()
return Result.success(result)
} catch (e: Throwable) {
lastError = e
// Если это последняя попытка или не стоит retry — выходим
if (attempt == maxRetries || !shouldRetry(e)) {
return Result.failure(e)
}
// Вычисляем задержку с exponential backoff
val delayMs = (initialDelayMs * backoffFactor.pow(attempt)).toLong()
val actualDelay = min(delayMs, maxDelayMs)
// Ждём перед следующей попыткой
delay(actualDelay)
}
}
// Теоретически недостижимо, но компилятор требует
return Result.failure(lastError ?: RuntimeException("Unknown error"))
}
/**
* Класс для конфигурации retry политики.
*/
class RetryPolicy(
val maxRetries: Int = 3,
val initialDelayMs: Long = 1_000L,
val maxDelayMs: Long = 30_000L,
val backoffFactor: Double = 2.0,
val shouldRetry: (Throwable) -> Boolean = { true }
) {
companion object {
/** Быстрый retry для лёгких операций */
val Quick = RetryPolicy(
maxRetries = 2,
initialDelayMs = 500L,
maxDelayMs = 2_000L,
backoffFactor = 2.0
)
/** Стандартный retry для сетевых операций */
val Network = RetryPolicy(
maxRetries = 3,
initialDelayMs = 1_000L,
maxDelayMs = 10_000L,
backoffFactor = 2.0
)
/** Консервативный retry для критичных операций */
val Conservative = RetryPolicy(
maxRetries = 5,
initialDelayMs = 2_000L,
maxDelayMs = 30_000L,
backoffFactor = 1.5
)
}
suspend operator fun <T> invoke(block: suspend () -> T): Result<T> {
return retryWithBackoff(
maxRetries = maxRetries,
initialDelayMs = initialDelayMs,
maxDelayMs = maxDelayMs,
backoffFactor = backoffFactor,
shouldRetry = shouldRetry,
block = block
)
}
}

View File

@@ -0,0 +1,134 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.SessionRepository
import javax.inject.Inject
/**
* ViewModel экрана входа/регистрации.
*/
@HiltViewModel
class AuthViewModel @Inject constructor(
private val sessionRepository: SessionRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<AuthUiState>(AuthUiState.Idle)
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _authSuccess = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authSuccess: SharedFlow<Unit> = _authSuccess.asSharedFlow()
/**
* Попытка автологина по сохранённому токену.
*/
fun tryAutoLogin() {
viewModelScope.launch {
val result = sessionRepository.autoLogin()
if (result.isSuccess) {
_authSuccess.emit(Unit)
}
}
}
/**
* Войти по логину/паролю.
* Использует SessionRepository — сохраняет UID в DataStore.
*/
fun login(username: String, password: String) {
viewModelScope.launch {
_uiState.value = AuthUiState.Loading
val result = sessionRepository.login(username, password)
_uiState.value = if (result.isSuccess) {
_authSuccess.emit(Unit)
AuthUiState.Success
} else {
AuthUiState.Error(result.exceptionOrNull()?.message ?: "Ошибка входа")
}
}
}
/**
* Зарегистрировать нового пользователя.
*/
fun register(username: String, password: String, displayName: String) {
viewModelScope.launch {
_uiState.value = AuthUiState.Loading
val result = sessionRepository.register(username, password, displayName)
_uiState.value = if (result.isSuccess) {
_authSuccess.emit(Unit)
AuthUiState.Success
} else {
val msg = result.exceptionOrNull()?.message ?: "Ошибка регистрации"
AuthUiState.Error(msg)
}
}
}
/**
* Зарегистрировать нового пользователя с полным профилем (email, телефон).
*/
fun registerWithFullProfile(
username: String,
password: String,
displayName: String,
email: String,
phone: String
) {
viewModelScope.launch {
_uiState.value = AuthUiState.Loading
val result = sessionRepository.registerWithFullProfile(username, password, displayName, email, phone)
_uiState.value = if (result.isSuccess) {
_authSuccess.emit(Unit)
AuthUiState.Success
} else {
val msg = result.exceptionOrNull()?.message ?: "Ошибка регистрации"
AuthUiState.Error(msg)
}
}
}
/**
* Проверить свободен ли username.
*/
fun checkUsername(username: String, callback: (Boolean) -> Unit) {
viewModelScope.launch {
val result = sessionRepository.tinodeClient.checkUsername(username)
callback(result.getOrDefault(true))
}
}
/**
* Проверить свободен ли email.
*/
fun checkEmailAvailability(email: String, callback: (Boolean) -> Unit) {
viewModelScope.launch {
val result = sessionRepository.tinodeClient.checkEmailAvailability(email)
callback(result.getOrDefault(true))
}
}
/**
* Проверить свободен ли телефон.
*/
fun checkPhoneAvailability(phone: String, callback: (Boolean) -> Unit) {
viewModelScope.launch {
val result = sessionRepository.tinodeClient.checkPhoneAvailability(phone)
callback(result.getOrDefault(true))
}
}
fun resetState() {
_uiState.value = AuthUiState.Idle
}
}
sealed class AuthUiState {
data object Idle : AuthUiState()
data object Loading : AuthUiState()
data object Success : AuthUiState()
data class Error(val message: String) : AuthUiState()
}

View File

@@ -0,0 +1,129 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.data.TinodeEvent
import ru.lastochka.messenger.data.model.MetaSub
import timber.log.Timber
import javax.inject.Inject
/**
* ViewModel списка чатов.
*/
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _contacts = MutableStateFlow<List<ContactInfo>>(emptyList())
val contacts: StateFlow<List<ContactInfo>> = _contacts.asStateFlow()
/**
* Суммарное количество непрочитанных сообщений.
* Используется для badge на табле "Чаты".
*/
val totalUnread: StateFlow<Int> = _contacts
.map { contacts -> contacts.sumOf { it.unread.coerceAtLeast(0) } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Фильтрованный список для UI
val filteredContacts: StateFlow<List<ContactInfo>> = combine(_contacts, _searchQuery) { contacts, query ->
if (query.isBlank()) contacts
else contacts.filter { it.displayName.contains(query, ignoreCase = true) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private var subs: List<MetaSub> = emptyList()
init {
loadContacts()
listenForUpdates()
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
private fun loadContacts() {
viewModelScope.launch {
try {
Timber.d("ChatListViewModel: loadContacts started")
_isLoading.value = true
subs = repository.getMeTopic()
Timber.d("ChatListViewModel: getMeTopic returned ${subs.size} subs")
// Не считаем SESSION_EXPIRED если только что залогинились.
// Даём серверу время отправить данные. Проверяем только
// если есть сохранённый UID НО authState == Unauthenticated
// (значит это был autoLogin с истёкшим токеном).
val authState = repository.authState.first()
if (subs.isEmpty() && authState !is ru.lastochka.messenger.data.AuthState.Authenticated) {
Timber.w("ChatListViewModel: empty subs + not authenticated → SESSION_EXPIRED")
repository.logout()
_error.value = "SESSION_EXPIRED"
}
delay(500)
refreshContacts()
_isLoading.value = false
Timber.d("ChatListViewModel: loadContacts finished, ${_contacts.value.size} contacts")
} catch (e: Exception) {
Timber.e(e, "ChatListViewModel: loadContacts error")
_error.value = e.message
_isLoading.value = false
}
}
}
private fun refreshContacts() {
val contacts = repository.getContactsFromSubs(subs)
_contacts.value = contacts
}
private fun listenForUpdates() {
viewModelScope.launch {
repository.events.collect { event ->
when (event) {
is TinodeEvent.Meta -> {
// Мета-событие ТОЛЬКО для me-топика — обновляем контакты
if (event.data.topic == "me") {
val newSubs = event.data.sub
if (newSubs != null) {
subs = newSubs
refreshContacts()
Timber.d("ChatListViewModel: updated contacts from meta event (${subs.size} subs)")
}
}
}
is TinodeEvent.Presence -> {
// Изменение присутствия — просто обновляем UI из текущих subs
refreshContacts()
}
else -> {}
}
}
}
}
fun refresh() {
refreshContacts()
}
fun clearError() {
_error.value = null
}
}

View File

@@ -0,0 +1,476 @@
package ru.lastochka.messenger.viewmodel
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.TinodeEvent
import ru.lastochka.messenger.data.UiMessage
import ru.lastochka.messenger.data.local.AppDatabase
import ru.lastochka.messenger.data.local.MessageEntity
import ru.lastochka.messenger.util.ImageCompressor
import timber.log.Timber
import java.util.*
import javax.inject.Inject
/**
* Тип действия с сообщением.
*/
enum class MessageActionType {
REPLY, COPY, EDIT, DELETE
}
/**
* ViewModel экрана чата.
*/
@HiltViewModel
class ChatViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: ChatRepository,
private val database: AppDatabase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val topicName: String = checkNotNull(savedStateHandle["topicName"])
private val _messages = MutableStateFlow<List<UiMessage>>(emptyList())
val messages: StateFlow<List<UiMessage>> = _messages.asStateFlow()
private val _topicTitle = MutableStateFlow("")
val topicTitle: StateFlow<String> = _topicTitle.asStateFlow()
private val _isTyping = MutableStateFlow(false)
val isTyping: StateFlow<Boolean> = _isTyping.asStateFlow()
// Job для авто-скрытия typing indicator — отменяется при новом kp
private var typingHideJob: kotlinx.coroutines.Job? = null
private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// State for message actions
private val _selectedMessage = MutableStateFlow<UiMessage?>(null)
val selectedMessage: StateFlow<UiMessage?> = _selectedMessage.asStateFlow()
private val _replyToMessage = MutableStateFlow<UiMessage?>(null)
val replyToMessage: StateFlow<UiMessage?> = _replyToMessage.asStateFlow()
private val _editingMessage = MutableStateFlow<UiMessage?>(null)
val editingMessage: StateFlow<UiMessage?> = _editingMessage.asStateFlow()
init {
loadTopic()
loadMessages()
listenForMessages()
}
private fun loadTopic() {
viewModelScope.launch {
_topicTitle.value = repository.getTopicTitle(topicName)
}
}
private fun loadMessages() {
viewModelScope.launch {
_isLoading.value = true
try {
repository.subscribeTopic(topicName)
database.messageDao().getMessagesForTopic(topicName).collect { entities ->
_messages.value = entities.map { entity ->
UiMessage(
seqId = entity.seqId,
from = entity.from,
senderName = entity.senderName,
content = entity.content,
timestamp = Date(entity.timestamp),
isOwn = entity.isOwn,
isRead = entity.isRead,
isEdited = entity.isEdited,
hasAttachment = entity.hasAttachment,
attachmentUrl = entity.attachmentUrl,
replyToContent = entity.replyToContent
)
}
_isLoading.value = false
}
} catch (e: Exception) {
_error.value = e.message
_isLoading.value = false
}
}
}
private fun listenForMessages() {
viewModelScope.launch {
repository.events.collect { event ->
when (event) {
is TinodeEvent.NewMessage -> {
val data = event.data
if (data.topic == topicName) {
// Эхо своего сообщения — обновляем локальную запись с tempSeqId на реальный seqId
val myUid = repository.myUid
if (data.from == myUid) {
// Эхо своего сообщения — просто пропускаем.
// Для текстовых сообщений: Room уже получил его из listenForMessages → saveMessageFromServer.
// Для изображений: URL уже сохранён в upload-функции через updateAttachmentFull(tempSeqId, ...).
// updateLastOwnAttachmentUrl УДАЛЁН — он обновлял ВСЕ сообщения с seqId < 0,
// заменяя разные картинки одной и той же (баг #2).
return@collect
}
saveMessageFromServer(data)
repository.markAsRead(topicName, data.seq)
}
}
is TinodeEvent.Info -> {
val info = event.data
if (info.topic == topicName && info.what == "kp") {
_isTyping.value = true
// Отменяем предыдущий таймер скрытия
typingHideJob?.cancel()
typingHideJob = viewModelScope.launch {
kotlinx.coroutines.delay(3000)
_isTyping.value = false
}
}
}
else -> {}
}
}
}
}
private suspend fun saveMessageFromServer(data: ru.lastochka.messenger.data.model.DataPacket) {
val content = data.content?.txt ?: ""
// Проверяем есть ли вложения (Drafty format)
var hasAttachment = false
var attachmentUrl: String? = null
var attachmentType: String? = null
var attachmentBase64: String? = null
val entities = data.content?.ent
if (!entities.isNullOrEmpty()) {
val firstEntity = entities.firstOrNull()
val entityData = firstEntity?.`data`
attachmentUrl = entityData?.ref
attachmentType = entityData?.mime
// Case 1: ref (URL from server upload)
if (attachmentUrl != null) {
hasAttachment = true
}
// Case 2: val (inline base64 — from web client)
else if (entityData?.val_str != null) {
val mime = entityData.mime ?: "image/jpeg"
attachmentUrl = "data:${mime};base64,${entityData.val_str}"
attachmentBase64 = attachmentUrl
hasAttachment = true
}
}
// Проверим extra attachments
if (!hasAttachment) {
val extraUrls = data.extra?.attachments
if (!extraUrls.isNullOrEmpty()) {
attachmentUrl = extraUrls.first()
attachmentType = "image/jpeg"
hasAttachment = true
}
}
// Определяем sender name
val senderName = if (data.from == repository.myUid) {
"Я"
} else {
data.from ?: ""
}
val entity = MessageEntity(
seqId = data.seq,
topicName = topicName,
from = data.from ?: "",
senderName = senderName,
content = if (hasAttachment && content == " ") "" else content,
rawContent = content,
timestamp = data.ts?.let { parseTimestamp(it) } ?: System.currentTimeMillis(),
isOwn = data.from == repository.myUid,
isRead = true,
isEdited = false,
hasAttachment = hasAttachment,
attachmentType = attachmentType,
attachmentUrl = attachmentUrl
)
database.messageDao().insertMessage(entity)
}
// ─── Message Actions ────────────────────────────────────────────
fun onMessageLongClick(message: UiMessage) {
_selectedMessage.value = message
}
fun dismissActionMenu() {
_selectedMessage.value = null
}
fun executeAction(action: MessageActionType) {
val msg = _selectedMessage.value ?: return
when (action) {
MessageActionType.COPY -> copyMessage(msg)
MessageActionType.REPLY -> replyToMessage(msg)
MessageActionType.EDIT -> editMessage(msg)
MessageActionType.DELETE -> deleteMessage(msg)
}
_selectedMessage.value = null
}
private fun copyMessage(message: UiMessage) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("message", message.content)
clipboard.setPrimaryClip(clip)
}
private fun replyToMessage(message: UiMessage) {
_replyToMessage.value = message
}
fun replyToMessageExternally(message: UiMessage) {
_replyToMessage.value = message
}
fun clearReply() {
_replyToMessage.value = null
}
private fun editMessage(message: UiMessage) {
if (message.isOwn) {
_editingMessage.value = message
}
}
fun clearEdit() {
_editingMessage.value = null
}
/**
* Отредактировать сообщение.
*/
fun editMessage(seqId: Int, newText: String) {
viewModelScope.launch {
try {
repository.editMessage(topicName, seqId, newText)
// Обновить локально
database.messageDao().updateMessageContent(seqId, newText, isEdited = true)
_editingMessage.value = null
} catch (e: Exception) {
_error.value = "Ошибка редактирования: ${e.message}"
}
}
}
private fun deleteMessage(message: UiMessage) {
if (!message.isOwn) return
viewModelScope.launch {
try {
// Удалить с сервера
val result = repository.deleteMessage(topicName, message.seqId)
if (result.isSuccess) {
// Удалить из локальной БД
database.messageDao().deleteMessageBySeqId(message.seqId)
} else {
_error.value = "Ошибка удаления: ${result.exceptionOrNull()?.message}"
}
} catch (e: Exception) {
_error.value = "Ошибка удаления: ${e.message}"
}
}
}
// ─── Pagination ────────────────────────────────────────────
/**
* Загрузить больше сообщений (при скролле вверх).
* Сервер отправляет DATA сообщения → event flow → listenForMessages → Room.
*/
fun loadMoreMessages() {
viewModelScope.launch {
val currentMessages = _messages.value
if (currentMessages.isEmpty()) return@launch
val minSeq = currentMessages.minOfOrNull { it.seqId } ?: return@launch
repository.loadMessagesBefore(topicName, minSeq, limit = 50)
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
viewModelScope.launch {
try {
repository.sendTextMessage(topicName, text)
val tempId = -(System.currentTimeMillis() % 100000).toInt()
val entity = MessageEntity(
seqId = tempId,
topicName = topicName,
from = "me",
senderName = "",
content = text,
rawContent = text,
timestamp = System.currentTimeMillis(),
isOwn = true,
isRead = false,
isEdited = false,
hasAttachment = false,
attachmentType = null,
attachmentUrl = null,
replyToSeq = _replyToMessage.value?.seqId,
replyToContent = _replyToMessage.value?.content
)
database.messageDao().insertMessage(entity)
// Clear reply state
_replyToMessage.value = null
} catch (e: Exception) {
_error.value = e.message
}
}
}
// State for image sending
private val _isSendingImage = MutableStateFlow(false)
val isSendingImage: StateFlow<Boolean> = _isSendingImage.asStateFlow()
// Progress 0.0..1.0
private val _imageUploadProgress = MutableStateFlow(0f)
val imageUploadProgress: StateFlow<Float> = _imageUploadProgress.asStateFlow()
fun sendImageMessage(
imageUri: android.net.Uri,
mimeType: String,
fileName: String,
caption: String = ""
) {
viewModelScope.launch {
// Определяем размер файла заранее для optimistic update
val fileSize = try {
context.contentResolver.openAssetFileDescriptor(imageUri, "r")?.length ?: 0L
} catch (e: Exception) { 0L }
// Текст для отобра в UI: caption или "[Изображение]"
val displayText = if (caption.isNotBlank()) caption else ""
// Optimistic update: сохраняем локальное сообщение ДО отправки
val tempSeqId = -(System.currentTimeMillis() % 100000).toInt()
val replyTo = _replyToMessage.value
val localEntity = MessageEntity(
seqId = tempSeqId,
topicName = topicName,
from = "me",
senderName = "",
content = displayText,
rawContent = displayText,
timestamp = System.currentTimeMillis(),
isOwn = true,
isRead = false,
isEdited = false,
hasAttachment = true,
attachmentType = mimeType,
attachmentUrl = null, // будет обновлено после успешной отправки
replyToSeq = replyTo?.seqId,
replyToContent = replyTo?.content
)
database.messageDao().insertMessage(localEntity)
_replyToMessage.value = null
try {
_isSendingImage.value = true
_imageUploadProgress.value = 0f
// 1. Compress image (если нужно)
val compressed = ImageCompressor.compressImage(context, imageUri)
val compressedUri = android.net.Uri.fromFile(compressed.file)
val effectiveMimeType = compressed.mimeType
val effectiveFileName = if (effectiveMimeType == "image/jpeg")
fileName.substringBeforeLast('.') + ".jpg" else fileName
if (compressed.wasCompressed) {
Timber.d(
"Image compressed: ${compressed.originalSize}${compressed.compressedSize} " +
"(${compressed.compressionPercent}% saved)"
)
}
// 2. Send with progress — передаём размер сжатого файла
val result = repository.sendImageMessageWithProgress(
topicName, compressedUri, effectiveMimeType,
effectiveFileName, displayText,
compressed.compressedSize
) { progress ->
_imageUploadProgress.value = progress
}
// 3. Clean up temp file
compressed.file.delete()
if (result.isSuccess) {
val serverUrl = result.getOrNull()
Timber.d("Image upload success: serverUrl=$serverUrl")
if (serverUrl != null) {
// Обновить локальное сообщение: проставить реальный URL файла
// Используем updateAttachmentFull чтобы гарантированно обновить все поля
database.messageDao().updateAttachmentFull(tempSeqId, serverUrl, mimeType)
Timber.d("Updated attachment for seqId=$tempSeqId to $serverUrl (type=$mimeType)")
}
} else {
_error.value = "Ошибка отправки изображения: ${result.exceptionOrNull()?.message}"
Timber.e(result.exceptionOrNull(), "Image upload failed")
}
} catch (e: Exception) {
_error.value = "Ошибка: ${e.message}"
Timber.e(e, "sendImageMessage failed")
} finally {
_isSendingImage.value = false
_imageUploadProgress.value = 0f
}
}
}
fun sendTyping() {
repository.sendTyping(topicName)
}
fun markAllRead() {
viewModelScope.launch {
val maxSeq = database.messageDao().getMaxSeq(topicName)
if (maxSeq != null) {
repository.markAsRead(topicName, maxSeq)
database.messageDao().markAllRead(topicName)
}
}
}
fun clearError() {
_error.value = null
}
private fun parseTimestamp(ts: String): Long {
return try {
val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US)
format.timeZone = java.util.TimeZone.getTimeZone("UTC")
format.parse(ts)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
}

View File

@@ -0,0 +1,31 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import javax.inject.Inject
/**
* ViewModel для экрана информации о контакте.
*/
@HiltViewModel
class ContactInfoViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
// Загрузка данных контакта
}
fun deleteChat() {
viewModelScope.launch {
// TODO: Реализация удаления чата
}
}
}

View File

@@ -0,0 +1,79 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.ContactInfo
import javax.inject.Inject
/**
* ViewModel для создания группы.
*/
@HiltViewModel
class CreateGroupViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _groupName = MutableStateFlow("")
val groupName: StateFlow<String> = _groupName.asStateFlow()
private val _description = MutableStateFlow("")
val description: StateFlow<String> = _description.asStateFlow()
private val _contacts = MutableStateFlow<List<ContactInfo>>(emptyList())
val contacts: StateFlow<List<ContactInfo>> = _contacts.asStateFlow()
private val _selectedMembers = MutableStateFlow<List<ContactInfo>>(emptyList())
val selectedMembers: StateFlow<List<ContactInfo>> = _selectedMembers.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
loadContacts()
}
private fun loadContacts() {
viewModelScope.launch {
// Загружаем контакты из me-топика
// В реальном приложении нужно подписаться на Flow
val subs = repository.getMeTopic()
_contacts.value = repository.getContactsFromSubs(subs)
}
}
fun updateGroupName(name: String) {
_groupName.value = name
}
fun updateDescription(desc: String) {
_description.value = desc
}
fun toggleMember(contact: ContactInfo) {
val current = _selectedMembers.value
if (current.contains(contact)) {
_selectedMembers.value = current - contact
} else {
_selectedMembers.value = current + contact
}
}
fun createGroup(onSuccess: (String) -> Unit) {
viewModelScope.launch {
_isLoading.value = true
try {
val members = _selectedMembers.value.map { it.topicName }
val result = repository.createGroup(groupName.value, description.value, members)
if (result.isSuccess) {
onSuccess(result.getOrNull() ?: "")
}
} finally {
_isLoading.value = false
}
}
}
}

View File

@@ -0,0 +1,66 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.ContactInfo
import javax.inject.Inject
/**
* ViewModel для поиска пользователей и создания нового чата.
*/
@HiltViewModel
class NewChatViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _searchResults = MutableStateFlow<List<ContactInfo>>(emptyList())
val searchResults: StateFlow<List<ContactInfo>> = _searchResults.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private var searchJob: Job? = null
/**
* Поиск пользователей по запросу с debounce.
*/
fun search(query: String) {
searchJob?.cancel()
if (query.length < 2) {
_searchResults.value = emptyList()
return
}
searchJob = viewModelScope.launch {
delay(400) // Debounce
_isLoading.value = true
try {
val results = repository.searchUsers(query)
_searchResults.value = results
} catch (e: Exception) {
_searchResults.value = emptyList()
} finally {
_isLoading.value = false
}
}
}
/**
* Начать чат с выбранным пользователем.
*/
suspend fun startChat(topicName: String): Result<Unit> {
return repository.startChatWithUser(topicName)
}
fun clearResults() {
_searchResults.value = emptyList()
searchJob?.cancel()
}
}

View File

@@ -0,0 +1,66 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import javax.inject.Inject
/**
* ViewModel для экрана профиля.
*/
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
private val _bio = MutableStateFlow("")
val bio: StateFlow<String> = _bio.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
loadProfile()
}
private fun loadProfile() {
viewModelScope.launch {
try {
val result = repository.getMyProfile()
if (result.isSuccess) {
val profile = result.getOrNull()
_name.value = profile?.displayName ?: ""
_bio.value = profile?.bio ?: ""
}
} catch (e: Exception) {
// Ошибка загрузки — оставляем пустые значения
}
}
}
fun updateName(newName: String) {
_name.value = newName
}
fun updateBio(newBio: String) {
_bio.value = newBio
}
suspend fun saveProfile() {
_isLoading.value = true
try {
val result = repository.updateProfile(name.value, bio.value)
if (result.isFailure) {
// Handle error if needed
}
} finally {
_isLoading.value = false
}
}
}

View File

@@ -0,0 +1,21 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import javax.inject.Inject
/**
* ViewModel для экрана настроек.
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
suspend fun logout() {
repository.logout()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item android:drawable="@color/colorSplashScreenBackground" />
<item>
<bitmap
android:src="@drawable/logo_src"
android:width="180dp"
android:height="180dp"
android:gravity="center"/>
</item>
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/brand_primary"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground_padded"/>
</adaptive-icon>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Inset wrapper для иконки лаунчера.
Добавляет 16% отступ (safe zone) чтобы лого не обрезалось
круглой/квадратной маской adaptive icon.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@mipmap/ic_launcher_foreground"
android:inset="16%" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/brand_primary"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground_padded"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash screen (dark mode) -->
<color name="colorSplashScreenBackground">#3B3E7F</color>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash Screen theme for dark mode -->
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/background_dark</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/logo_splash</item>
<item name="windowSplashScreenAnimationDuration">800</item>
<item name="splashScreenIconSize">96dp</item>
<item name="postSplashScreenTheme">@style/Theme.Lаstochka</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style>
</resources>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Lastochka brand colors -->
<color name="brand_primary">#5B5EF4</color>
<color name="brand_primary_dark">#4338CA</color>
<color name="brand_secondary">#7B61FF</color>
<color name="brand_secondary_dark">#5C45D6</color>
<color name="brand_accent">#FFD93D</color>
<!-- Light theme -->
<color name="surface">#FFFFFF</color>
<color name="surface_variant">#F5F5F5</color>
<color name="background">#EFEFF3</color>
<color name="on_surface">#1A1A1A</color>
<color name="on_surface_variant">#666666</color>
<color name="outline">#E0E0E0</color>
<!-- Message bubbles (light) -->
<color name="bubble_own">#EEF2FF</color>
<color name="bubble_peer">#FFFFFF</color>
<color name="bubble_own_text">#1A1A1A</color>
<color name="bubble_peer_text">#1A1A1A</color>
<!-- Dark theme -->
<color name="surface_dark">#17212B</color>
<color name="surface_variant_dark">#1E2C3A</color>
<color name="background_dark">#0E1621</color>
<color name="on_surface_dark">#F5F5F5</color>
<color name="on_surface_variant_dark">#9E9E9E</color>
<color name="outline_dark">#2B3A4A</color>
<!-- Message bubbles (dark) -->
<color name="bubble_own_dark">#2B5278</color>
<color name="bubble_peer_dark">#182533</color>
<color name="bubble_own_text_dark">#E8E8E8</color>
<color name="bubble_peer_text_dark">#E8E8E8</color>
<!-- Status -->
<color name="online">#40C040</color>
<color name="read_receipt">#5B5EF4</color>
<color name="sent_receipt">#9E9E9E</color>
<!-- Selection -->
<color name="selection_overlay">#2F3F51B5</color>
<!-- Splash screen -->
<color name="colorSplashScreenBackground">#5B5EF4</color>
<!-- Launcher -->
<color name="launcherBackground">#5B5EF4</color>
</resources>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Ласточка</string>
<string name="app_name_full">Ласточка Мессенджер</string>
<!-- Login -->
<string name="login_title">Вход в Ласточку</string>
<string name="login_username">Имя пользователя или телефон</string>
<string name="login_password">Пароль</string>
<string name="login_button">Войти</string>
<string name="login_no_account">Нет аккаунта? Зарегистрироваться</string>
<string name="login_error">Неверное имя или пароль</string>
<string name="login_host">Сервер</string>
<!-- Registration -->
<string name="register_title">Регистрация</string>
<string name="register_name">Ваше имя</string>
<string name="register_name_optional">(необязательно)</string>
<string name="register_username">Логин</string>
<string name="register_email">Email</string>
<string name="register_phone">Телефон</string>
<string name="register_password">Пароль</string>
<string name="register_password_confirm">Подтверждение пароля</string>
<string name="register_button">Зарегистрироваться</string>
<string name="register_have_account">Уже есть аккаунт? Войти</string>
<string name="register_error">Ошибка регистрации</string>
<string name="register_password_mismatch">Пароли не совпадают</string>
<string name="register_username_taken">Этот логин уже занят</string>
<string name="register_email_taken">Этот email уже зарегистрирован</string>
<string name="register_phone_taken">Этот номер уже зарегистрирован</string>
<string name="register_username_short">Логин должен быть не менее 3 символов</string>
<string name="register_username_invalid">Логин может содержать только буквы, цифры и подчёркивание</string>
<string name="register_email_invalid">Введите корректный email</string>
<string name="register_phone_invalid">Введите корректный номер телефона</string>
<string name="register_password_short">Пароль должен быть не менее 6 символов</string>
<string name="register_username_available">Логин доступен</string>
<string name="register_email_available">Email доступен</string>
<string name="register_phone_available">Номер доступен</string>
<string name="register_terms">Регистрируясь, вы принимаете условия использования и политику конфиденциальности</string>
<!-- Chat list -->
<string name="chats_title">Чаты</string>
<string name="chats_empty">Нет чатов</string>
<string name="chats_empty_hint">Нажмите + чтобы начать диалог</string>
<string name="chat_new">Новый чат</string>
<!-- Chat -->
<string name="chat_typing">печатает…</string>
<string name="chat_online">в сети</string>
<string name="chat_offline">не в сети</string>
<string name="chat_last_seen">был(а) %s</string>
<string name="chat_message_hint">Сообщение…</string>
<string name="chat_send">Отправить</string>
<string name="chat_edit">Редактировать</string>
<string name="chat_delete">Удалить</string>
<string name="chat_forward">Переслать</string>
<string name="chat_reply">Ответить</string>
<string name="chat_copy">Копировать</string>
<string name="chat_edited">(ред.)</string>
<!-- Profile -->
<string name="profile_title">Профиль</string>
<string name="profile_logout">Выйти</string>
<string name="profile_name">Имя</string>
<string name="profile_username">Имя пользователя</string>
<!-- Common -->
<string name="today">Сегодня</string>
<string name="yesterday">Вчера</string>
<string name="you">Вы</string>
<string name="just_now">только что</string>
<string name="muted">Без звука</string>
<string name="pinned">Закреплено</string>
<!-- Errors -->
<string name="error_network">Нет подключения к интернету</string>
<string name="error_server">Ошибка сервера</string>
<string name="error_unknown">Неизвестная ошибка</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Splash Screen theme (Android 12+) -->
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/brand_primary</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/logo_splash</item>
<item name="windowSplashScreenAnimationDuration">800</item>
<item name="splashScreenIconSize">96dp</item>
<item name="postSplashScreenTheme">@style/Theme.Lаstochka</item>
<item name="android:statusBarColor">@color/brand_primary</item>
<item name="android:navigationBarColor">@color/brand_primary</item>
</style>
<!-- Main app theme (will be overridden by Compose) -->
<style name="Theme.Lаstochka" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/brand_primary</item>
<item name="colorPrimaryDark">@color/brand_primary_dark</item>
<item name="colorAccent">@color/brand_secondary</item>
<item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera_images" path="." />
<external-cache-path name="external_camera_images" path="." />
</paths>

View File

@@ -0,0 +1,135 @@
package ru.lastochka.messenger.data
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.*
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import ru.lastochka.messenger.service.NetworkMonitor
/**
* Тесты для SessionRepository.
* Проверяем auth операции и состояния.
*
* Примечание: DataStore мокается через relaxed mock,
* поэтому тесты фокусируются на auth логике.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class SessionRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var tinodeClient: TinodeClient
private lateinit var networkMonitor: NetworkMonitor
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
tinodeClient = mockk(relaxed = true)
networkMonitor = mockk(relaxed = true)
every { networkMonitor.isConnected } returns MutableStateFlow(true)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `TinodeClient myUid returns correct value`() = testScope.runTest {
every { tinodeClient.myUid } returns "usrTestUid"
Assert.assertEquals("usrTestUid", tinodeClient.myUid)
}
@Test
fun `TinodeClient connectionState is observable`() = testScope.runTest {
val connectionStateFlow = MutableStateFlow(TinodeConnState.Connected)
every { tinodeClient.connectionState } returns connectionStateFlow
val state = connectionStateFlow.value
Assert.assertEquals(TinodeConnState.Connected, state)
}
@Test
fun `AuthState sealed class works correctly`() = testScope.runTest {
// Test Unauthenticated
val unauth: AuthState = AuthState.Unauthenticated
Assert.assertTrue(unauth is AuthState.Unauthenticated)
// Test Authenticated
val auth: AuthState = AuthState.Authenticated("usr123")
Assert.assertTrue(auth is AuthState.Authenticated)
Assert.assertEquals("usr123", (auth as AuthState.Authenticated).uid)
// Test SessionExpired
val expired: AuthState = AuthState.SessionExpired
Assert.assertTrue(expired is AuthState.SessionExpired)
}
@Test
fun `TinodeConnState enum works correctly`() = testScope.runTest {
val states = listOf(
TinodeConnState.Disconnected,
TinodeConnState.Connecting,
TinodeConnState.Connected,
TinodeConnState.Authenticated,
TinodeConnState.Error
)
Assert.assertEquals(5, states.size)
}
@Test
fun `login result can be checked`() = testScope.runTest {
coEvery { tinodeClient.login("user", "pass") } returns Result.success(Unit)
val result = tinodeClient.login("user", "pass")
Assert.assertTrue(result.isSuccess)
}
@Test
fun `login failure can be checked`() = testScope.runTest {
val error = Exception("Неверный пароль")
coEvery { tinodeClient.login("user", "wrong") } returns Result.failure<Unit>(error)
val result = tinodeClient.login("user", "wrong")
Assert.assertTrue(result.isFailure)
Assert.assertEquals("Неверный пароль", result.exceptionOrNull()?.message)
}
@Test
fun `register result can be checked`() = testScope.runTest {
coEvery { tinodeClient.register("new", "pass", "New") } returns Result.success(Unit)
val result = tinodeClient.register("new", "pass", "New")
Assert.assertTrue(result.isSuccess)
}
@Test
fun `autoLogin result can be checked`() = testScope.runTest {
coEvery { tinodeClient.autoLogin() } returns Result.success(Unit)
val result = tinodeClient.autoLogin()
Assert.assertTrue(result.isSuccess)
}
@Test
fun `logout can be called`() = testScope.runTest {
coEvery { tinodeClient.logout() } just Runs
tinodeClient.logout()
coVerify { tinodeClient.logout() }
}
}

View File

@@ -0,0 +1,115 @@
package ru.lastochka.messenger.data.local
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.*
/**
* Тесты для ContactDao.
*/
class ContactDaoTest {
@Test
fun insertAndReadContact() = runBlocking {
val contact = ContactEntity(
topicName = "usrABC123",
displayName = "Иван Иванов",
avatar = null,
lastMessage = "Привет!",
lastMessageTime = System.currentTimeMillis(),
unread = 1,
isGroup = false,
muted = false,
pinned = false
)
Assert.assertEquals("usrABC123", contact.topicName)
Assert.assertEquals("Иван Иванов", contact.displayName)
Assert.assertEquals(1, contact.unread)
}
@Test
fun clearUnread() = runBlocking {
val contact = ContactEntity(
topicName = "usrABC123",
displayName = "Иван",
avatar = null,
lastMessage = null,
lastMessageTime = System.currentTimeMillis(),
unread = 5,
isGroup = false,
muted = false,
pinned = false
)
Assert.assertEquals(5, contact.unread)
// После clearUnread: unread = 0
}
@Test
fun contactIsGroup() = runBlocking {
val group = ContactEntity(
topicName = "grpXYZ789",
displayName = "Рабочий чат",
avatar = null,
lastMessage = null,
lastMessageTime = System.currentTimeMillis(),
unread = 0,
isGroup = true,
muted = false,
pinned = false
)
val p2p = ContactEntity(
topicName = "usrABC123",
displayName = "Иван",
avatar = null,
lastMessage = null,
lastMessageTime = System.currentTimeMillis(),
unread = 0,
isGroup = false,
muted = false,
pinned = false
)
Assert.assertTrue(group.isGroup)
Assert.assertFalse(p2p.isGroup)
Assert.assertTrue(group.topicName.startsWith("grp"))
}
@Test
fun mutedContact() = runBlocking {
val contact = ContactEntity(
topicName = "usrABC123",
displayName = "Спам",
avatar = null,
lastMessage = null,
lastMessageTime = System.currentTimeMillis(),
unread = 100,
isGroup = false,
muted = true,
pinned = false
)
Assert.assertTrue(contact.muted)
Assert.assertEquals(100, contact.unread)
}
@Test
fun pinnedContact() = runBlocking {
val pinned = ContactEntity(
topicName = "usrABC123",
displayName = "Босс",
avatar = null,
lastMessage = null,
lastMessageTime = 1000,
unread = 0,
isGroup = false,
muted = false,
pinned = true
)
Assert.assertTrue(pinned.pinned)
// Pinned контакты должны идти первыми в списке
}
}

View File

@@ -0,0 +1,140 @@
package ru.lastochka.messenger.data.local
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.IOException
/**
* Тесты для MessageDao.
* Тестируем вставку, чтение, удаление и обновление сообщений.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class MessageDaoTest {
private lateinit var database: AppDatabase
private lateinit var dao: MessageDao
@Before
fun createDb() {
// Используем in-memory базу для тестов
// Примечание: Room.inMemoryDatabaseBuilder требует контекста
// Для unit-тестов используем заглушку
// В реальных тестах использовать Android instrumented tests
}
@After
@Throws(IOException::class)
fun closeDb() {
// database.close()
}
@Test
fun insertAndReadMessage() = runBlocking {
// Для unit-тестов без Android — проверяем логику
val entity = MessageEntity(
seqId = 1,
topicName = "usrABC123",
from = "usrDEF456",
senderName = "Иван",
content = "Привет!",
rawContent = "Привет!",
timestamp = System.currentTimeMillis(),
isOwn = false,
isRead = false,
isEdited = false,
hasAttachment = false,
attachmentType = null,
attachmentUrl = null
)
// В реальном instrumented тесте:
// dao.insertMessage(entity)
// val messages = dao.getMessagesForTopic("usrABC123").first()
// Assert.assertEquals(1, messages.size)
Assert.assertEquals("Привет!", entity.content)
Assert.assertFalse(entity.isOwn)
}
@Test
fun markAllRead() = runBlocking {
// Проверка логики: markAllRead устанавливает isRead = true
val entity = MessageEntity(
seqId = 1,
topicName = "usrABC123",
from = "usrDEF456",
senderName = "Иван",
content = "Тест",
rawContent = "Тест",
timestamp = System.currentTimeMillis(),
isOwn = false,
isRead = false,
isEdited = false,
hasAttachment = false,
attachmentType = null,
attachmentUrl = null
)
// После markAllRead: isRead = true
Assert.assertFalse(entity.isRead)
// После вызова DAO: entity.isRead должно стать true
}
@Test
fun deleteMessageBySeqId() = runBlocking {
// Проверка: удаление по seqId работает
val entity = MessageEntity(
seqId = 42,
topicName = "usrABC123",
from = "me",
senderName = "",
content = "Удали меня",
rawContent = "Удали меня",
timestamp = System.currentTimeMillis(),
isOwn = true,
isRead = false,
isEdited = false,
hasAttachment = false,
attachmentType = null,
attachmentUrl = null
)
Assert.assertEquals(42, entity.seqId)
Assert.assertTrue(entity.isOwn)
}
@Test
fun getMaxSeq() = runBlocking {
// Проверка: getMaxSeq возвращает максимальный seqId
// При пустой таблице — null
// После вставки сообщений с seqId 1, 2, 3 — возвращает 3
}
@Test
fun messageWithReply() = runBlocking {
// Проверка: сообщение с replyToContent сохраняется
val entity = MessageEntity(
seqId = 5,
topicName = "usrABC123",
from = "me",
senderName = "",
content = "Ответ",
rawContent = "Ответ",
timestamp = System.currentTimeMillis(),
isOwn = true,
isRead = false,
isEdited = false,
hasAttachment = false,
attachmentType = null,
attachmentUrl = null,
replyToSeq = 3,
replyToContent = "Оригинальное сообщение"
)
Assert.assertEquals(3, entity.replyToSeq)
Assert.assertEquals("Оригинальное сообщение", entity.replyToContent)
}
}

View File

@@ -0,0 +1,150 @@
package ru.lastochka.messenger.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
/**
* Тесты для retryWithBackoff утилиты.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RetryWithBackoffTest {
@Test
fun `success on first attempt returns result`() = runTest {
var callCount = 0
val result = retryWithBackoff(maxRetries = 3, initialDelayMs = 10L) {
callCount++
"success"
}
assertTrue(result.isSuccess)
assertEquals("success", result.getOrNull())
assertEquals(1, callCount)
}
@Test
fun `retries on failure and eventually succeeds`() = runTest {
var callCount = 0
val result = retryWithBackoff(maxRetries = 3, initialDelayMs = 10L) {
callCount++
if (callCount < 3) {
throw RuntimeException("Temporary error")
}
"success"
}
assertTrue(result.isSuccess)
assertEquals("success", result.getOrNull())
assertEquals(3, callCount)
}
@Test
fun `fails after max retries exhausted`() = runTest {
var callCount = 0
val result = retryWithBackoff(maxRetries = 2, initialDelayMs = 10L) {
callCount++
throw RuntimeException("Persistent error")
}
assertTrue(result.isFailure)
assertEquals("Persistent error", result.exceptionOrNull()?.message)
// 1 начальная попытка + 2 retry = 3 total
assertEquals(3, callCount)
}
@Test
fun `shouldRetry predicate can stop retries`() = runTest {
var callCount = 0
// Простой тест — shouldRetry = false сразу останавливает
val result = retryWithBackoff(
maxRetries = 5,
initialDelayMs = 10L,
shouldRetry = { false } // Никогда не retry
) {
callCount++
throw RuntimeException("Error")
}
assertTrue(result.isFailure)
// Только 1 попыка — no retries
assertEquals(1, callCount)
}
@Test
fun `RetryPolicy Quick has fewer retries`() = runTest {
var callCount = 0
val result = RetryPolicy.Quick {
callCount++
throw RuntimeException("Error")
}
assertTrue(result.isFailure)
// Quick: maxRetries = 2, так что 3 попытки total
assertEquals(3, callCount)
}
@Test
fun `RetryPolicy Network has standard retries`() = runTest {
var callCount = 0
val result = RetryPolicy.Network {
callCount++
throw RuntimeException("Error")
}
assertTrue(result.isFailure)
// Network: maxRetries = 3, так что 4 попытки total
assertEquals(4, callCount)
}
@Test
fun `RetryPolicy Conservative has more retries`() = runTest {
var callCount = 0
val result = RetryPolicy.Conservative {
callCount++
throw RuntimeException("Error")
}
assertTrue(result.isFailure)
// Conservative: maxRetries = 5, так что 6 попыток total
assertEquals(6, callCount)
}
@Test
fun `RetryPolicy invoke operator works`() = runTest {
val policy = RetryPolicy(maxRetries = 1, initialDelayMs = 10L)
var callCount = 0
val result = policy {
callCount++
if (callCount < 2) {
throw RuntimeException("Error")
}
"success"
}
assertTrue(result.isSuccess)
assertEquals(2, callCount)
}
@Test
fun `backoff delay calculation works correctly`() = runTest {
// Простой тест что retry происходит с задержкой
var callCount = 0
val result = retryWithBackoff(
maxRetries = 1,
initialDelayMs = 10L,
backoffFactor = 2.0
) {
callCount++
if (callCount < 2) {
throw RuntimeException("Error")
}
"success"
}
assertTrue(result.isSuccess)
assertEquals(2, callCount)
}
}

View File

@@ -0,0 +1,144 @@
package ru.lastochka.messenger.viewmodel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import ru.lastochka.messenger.data.SessionRepository
/**
* Тесты для AuthViewModel.
* Проверяем login, register, validation.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class AuthViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var sessionRepository: SessionRepository
private lateinit var viewModel: AuthViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
sessionRepository = mockk(relaxed = true)
viewModel = AuthViewModel(sessionRepository)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `login success changes state to Success`() = testScope.runTest {
coEvery { sessionRepository.login("testuser", "password123") } returns Result.success(Unit)
viewModel.login("testuser", "password123")
val state = viewModel.uiState.value
Assert.assertTrue(state is AuthUiState.Success)
}
@Test
fun `login failure changes state to Error`() = testScope.runTest {
val error = Exception("Нет подключения к серверу")
coEvery { sessionRepository.login("testuser", "password123") } returns Result.failure<Unit>(error)
viewModel.login("testuser", "password123")
val state = viewModel.uiState.value
Assert.assertTrue(state is AuthUiState.Error)
Assert.assertEquals("Нет подключения к серверу", (state as AuthUiState.Error).message)
}
@Test
fun `register success changes state to Success`() = testScope.runTest {
coEvery { sessionRepository.register("newuser", "password123", "New User") } returns Result.success(Unit)
viewModel.register("newuser", "password123", "New User")
val state = viewModel.uiState.value
Assert.assertTrue(state is AuthUiState.Success)
}
@Test
fun `register failure changes state to Error`() = testScope.runTest {
coEvery {
sessionRepository.register("existing", "password123", "Existing")
} returns Result.failure<Unit>(Exception("User already exists"))
viewModel.register("existing", "password123", "Existing")
val state = viewModel.uiState.value
Assert.assertTrue(state is AuthUiState.Error)
}
@Test
fun `register with full profile success`() = testScope.runTest {
coEvery {
sessionRepository.registerWithFullProfile("user", "pass", "User", "user@test.com", "+79001234567")
} returns Result.success(Unit)
viewModel.registerWithFullProfile("user", "pass", "User", "user@test.com", "+79001234567")
val state = viewModel.uiState.value
Assert.assertTrue(state is AuthUiState.Success)
}
@Test
fun `reset state returns Idle`() = testScope.runTest {
coEvery { sessionRepository.login("user", "pass") } returns Result.failure(Exception("Error"))
viewModel.login("user", "pass")
Assert.assertTrue(viewModel.uiState.value is AuthUiState.Error)
viewModel.resetState()
Assert.assertTrue(viewModel.uiState.value is AuthUiState.Idle)
}
@Test
fun `check username availability`() = testScope.runTest {
coEvery { sessionRepository.tinodeClient.checkUsername("freeuser") } returns Result.success(true)
var result: Boolean? = null
viewModel.checkUsername("freeuser") { isAvailable ->
result = isAvailable
}
Assert.assertTrue(result == true)
}
@Test
fun `check username taken`() = testScope.runTest {
coEvery { sessionRepository.tinodeClient.checkUsername("taken") } returns Result.success(false)
var result: Boolean? = null
viewModel.checkUsername("taken") { isAvailable ->
result = isAvailable
}
Assert.assertFalse(result == true)
}
@Test
fun `empty password login returns error`() = testScope.runTest {
// ViewModel отправляет пустой пароль — сервер вернёт ошибку
coEvery { sessionRepository.login("", "") } returns Result.failure<Unit>(Exception("Ошибка входа"))
viewModel.login("", "")
val state = viewModel.uiState.value
Assert.assertTrue(state is AuthUiState.Error)
}
}

View File

@@ -0,0 +1,105 @@
package ru.lastochka.messenger.viewmodel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.*
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import ru.lastochka.messenger.data.AuthState
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.data.TinodeConnState
import ru.lastochka.messenger.data.TinodeEvent
import ru.lastochka.messenger.data.model.MetaSub
/**
* Тесты для ChatListViewModel.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class ChatListViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var repository: ChatRepository
private lateinit var viewModel: ChatListViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
repository = mockk(relaxed = true)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `contacts load on init`() = testScope.runTest {
val theCard = ru.lastochka.messenger.data.model.TheCard(fn = "Иван")
val subs = listOf(
MetaSub(
user = "usrABC",
topic = "usrABC",
`public` = theCard
)
)
val contacts = listOf(ContactInfo(topicName = "usrABC", displayName = "Иван"))
coEvery { repository.getMeTopic() } returns subs
coEvery { repository.getContactsFromSubs(any()) } returns contacts
coEvery { repository.hasSavedToken() } returns false
every { repository.events } returns MutableSharedFlow()
viewModel = ChatListViewModel(repository)
// Проверяем что contacts были загружены
coVerify { repository.getMeTopic() }
}
@Test
fun `search query filters contacts`() = testScope.runTest {
val contacts = listOf(
ContactInfo(topicName = "usr1", displayName = "Иван"),
ContactInfo(topicName = "usr2", displayName = "Мария"),
ContactInfo(topicName = "usr3", displayName = "Алексей")
)
coEvery { repository.getMeTopic() } returns emptyList()
coEvery { repository.hasSavedToken() } returns false
every { repository.events } returns MutableSharedFlow()
viewModel = ChatListViewModel(repository)
// Проверяем логику фильтрации
val filtered = contacts.filter { it.displayName.contains("иван", ignoreCase = true) }
Assert.assertEquals(1, filtered.size)
Assert.assertEquals("Иван", filtered[0].displayName)
}
@Test
fun `session expired triggers logout`() = testScope.runTest {
coEvery { repository.getMeTopic() } returns emptyList()
coEvery { repository.hasSavedToken() } returns true
coEvery { repository.logout() } just runs
every { repository.events } returns MutableSharedFlow()
viewModel = ChatListViewModel(repository)
// При пустых subs и наличии токена — logout
coVerify { repository.getMeTopic() }
coVerify { repository.hasSavedToken() }
}
}

View File

@@ -0,0 +1,255 @@
package ru.lastochka.messenger.viewmodel
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.*
import org.junit.*
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import ru.lastochka.messenger.data.*
import ru.lastochka.messenger.data.local.AppDatabase
import ru.lastochka.messenger.data.local.MessageDao
import ru.lastochka.messenger.data.model.DataPacket
import ru.lastochka.messenger.data.model.PubContent
import java.util.*
/**
* Тесты для ChatViewModel.
* Проверяем отправку сообщений, receive, typing, delete, reply.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class ChatViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var context: android.content.Context
private lateinit var repository: ChatRepository
private lateinit var database: AppDatabase
private lateinit var messageDao: MessageDao
private lateinit var viewModel: ChatViewModel
private val eventsFlow = MutableSharedFlow<TinodeEvent>(extraBufferCapacity = 8)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
context = mockk(relaxed = true)
repository = mockk(relaxed = true)
database = mockk(relaxed = true)
messageDao = mockk(relaxed = true)
every { database.messageDao() } returns messageDao
coEvery { messageDao.getMessagesForTopic(any()) } returns flowOf(emptyList())
coEvery { repository.getTopicTitle(any()) } returns "Test Chat"
every { repository.events } returns eventsFlow
coEvery { repository.subscribeTopic(any()) } returns Result.success(Unit)
// Mock SavedStateHandle без Android зависимости
val savedStateHandle = createSavedStateHandle()
viewModel = ChatViewModel(context, repository, database, savedStateHandle)
}
private fun createSavedStateHandle(): androidx.lifecycle.SavedStateHandle {
return androidx.lifecycle.SavedStateHandle(mapOf("topicName" to "usrABC123"))
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `send message adds to local DB`() = testScope.runTest {
coEvery { messageDao.insertMessage(any()) } returns Unit
viewModel.sendMessage("Привет!")
coVerify { messageDao.insertMessage(match { it.content == "Привет!" && it.isOwn }) }
}
@Test
fun `send empty message does nothing`() = testScope.runTest {
viewModel.sendMessage("")
viewModel.sendMessage(" ")
coVerify(exactly = 0) { messageDao.insertMessage(any()) }
}
@Test
fun `receive message from server`() = testScope.runTest {
val dataPacket = DataPacket(
topic = "usrABC123",
from = "usrDEF456",
seq = 10,
content = PubContent(txt = "Ответ от сервера"),
ts = "2026-04-03T12:00:00.000Z"
)
eventsFlow.emit(TinodeEvent.NewMessage(dataPacket))
// Сообщение должно быть сохранено в DB
coVerify { messageDao.insertMessage(any()) }
}
@Test
fun `typing indicator sent`() = testScope.runTest {
every { repository.sendTyping(any()) } just Runs
viewModel.sendTyping()
verify { repository.sendTyping("usrABC123") }
}
@Test
fun `delete message removes from DB`() = testScope.runTest {
val message = UiMessage(
seqId = 5,
from = "me",
senderName = "",
content = "Удали меня",
timestamp = Date(),
isOwn = true,
isRead = false,
isEdited = false
)
coEvery { repository.deleteMessage(any(), any()) } returns Result.success(Unit)
coEvery { messageDao.deleteMessageBySeqId(any()) } returns Unit
viewModel.onMessageLongClick(message)
viewModel.executeAction(MessageActionType.DELETE)
coVerify { messageDao.deleteMessageBySeqId(5) }
}
@Test
fun `delete non-own message does nothing`() = testScope.runTest {
val message = UiMessage(
seqId = 5,
from = "usrDEF456",
senderName = "Иван",
content = "Не удаляй",
timestamp = Date(),
isOwn = false,
isRead = true,
isEdited = false
)
viewModel.onMessageLongClick(message)
viewModel.executeAction(MessageActionType.DELETE)
coVerify(exactly = 0) { messageDao.deleteMessageBySeqId(any()) }
}
@Test
fun `reply to message sets reply state`() = testScope.runTest {
val message = UiMessage(
seqId = 3,
from = "usrDEF456",
senderName = "Иван",
content = "Ответь на это",
timestamp = Date(),
isOwn = false,
isRead = true,
isEdited = false
)
viewModel.onMessageLongClick(message)
viewModel.executeAction(MessageActionType.REPLY)
Assert.assertEquals(message, viewModel.replyToMessage.value)
}
@Test
fun `clear reply resets reply state`() = testScope.runTest {
val message = UiMessage(
seqId = 3,
from = "usrDEF456",
senderName = "Иван",
content = "Тест",
timestamp = Date(),
isOwn = false,
isRead = true,
isEdited = false
)
viewModel.replyToMessageExternally(message)
Assert.assertNotNull(viewModel.replyToMessage.value)
viewModel.clearReply()
Assert.assertNull(viewModel.replyToMessage.value)
}
@Test
fun `copy message action clears selection`() = testScope.runTest {
val message = UiMessage(
seqId = 1,
from = "usrDEF456",
senderName = "Иван",
content = "Скопируй меня",
timestamp = Date(),
isOwn = false,
isRead = true,
isEdited = false
)
viewModel.onMessageLongClick(message)
Assert.assertNotNull(viewModel.selectedMessage.value)
// Copy action вызывает ClipboardManager который недоступен в unit-тестах
// Проверяем только что selectedMessage установлен
Assert.assertEquals(message, viewModel.selectedMessage.value)
}
@Test
fun `loadMoreMessages called on scroll to top`() = testScope.runTest {
coEvery { repository.loadMessagesBefore(any(), any(), any()) } just Runs
viewModel.loadMoreMessages()
// При пустом списке loadMoreMessages ничего не делает
coVerify(exactly = 0) { repository.loadMessagesBefore(any(), any(), any()) }
}
@Test
fun `markAllRead updates DB`() = testScope.runTest {
coEvery { messageDao.getMaxSeq(any()) } returns 10
coEvery { messageDao.markAllRead(any()) } returns Unit
every { repository.markAsRead(any(), any()) } just Runs
viewModel.markAllRead()
coVerify { messageDao.markAllRead("usrABC123") }
verify { repository.markAsRead("usrABC123", 10) }
}
@Test
fun `edit message updates DB`() = testScope.runTest {
val message = UiMessage(
seqId = 5,
from = "me",
senderName = "",
content = "Старый текст",
timestamp = Date(),
isOwn = true,
isRead = false,
isEdited = false
)
coEvery { repository.editMessage(any(), any(), any()) } just Runs
coEvery { messageDao.updateMessageContent(any(), any(), any()) } returns Unit
viewModel.onMessageLongClick(message)
viewModel.executeAction(MessageActionType.EDIT)
viewModel.editMessage(5, "Новый текст")
coVerify { messageDao.updateMessageContent(5, "Новый текст", true) }
}
}