first commit
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
*.app
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
BIN
lastochka-android-compose/.gradle/8.7/checksums/checksums.lock
Normal file
BIN
lastochka-android-compose/.gradle/8.7/fileChanges/last-build.bin
Normal file
BIN
lastochka-android-compose/.gradle/8.7/fileHashes/fileHashes.bin
Normal file
BIN
lastochka-android-compose/.gradle/8.7/fileHashes/fileHashes.lock
Normal file
0
lastochka-android-compose/.gradle/8.7/gc.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#Fri Apr 03 10:41:18 MSK 2026
|
||||||
|
gradle.version=8.7
|
||||||
2
lastochka-android-compose/.gradle/config.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#Fri Apr 03 13:13:31 MSK 2026
|
||||||
|
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||||
BIN
lastochka-android-compose/.gradle/file-system.probe
Normal file
348
lastochka-android-compose/ARCHITECTURE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# Улучшения архитектуры мобильного приложения
|
||||||
|
|
||||||
|
> Дата: 4 апреля 2026 г.
|
||||||
|
|
||||||
|
## Резюме изменений
|
||||||
|
|
||||||
|
Выполнены все 5 рекомендаций по улучшению архитектуры Android-приложения «Ласточка».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ✅ Splash Screen
|
||||||
|
|
||||||
|
**Проблема:** При запуске приложения на секунду появлялся основной интерфейс, затем экран авторизации.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
### Добавлена зависимость
|
||||||
|
```kotlin
|
||||||
|
// app/build.gradle.kts
|
||||||
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Создана тема для Splash Screen
|
||||||
|
- `res/values/themes.xml` — `Theme.App.Starting` (Android 12+)
|
||||||
|
- `res/values-night/themes.xml` — ночная тема
|
||||||
|
- Использует `Theme.SplashScreen` с `windowSplashScreenAnimatedIcon`
|
||||||
|
|
||||||
|
### Обновлён AndroidManifest
|
||||||
|
```xml
|
||||||
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Интеграция в MainActivity
|
||||||
|
```kotlin
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
val splashScreen = installSplashScreen()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
var keepSplashOnScreen = true
|
||||||
|
splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
|
||||||
|
|
||||||
|
LaunchedEffect(authState) {
|
||||||
|
keepSplashOnScreen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:** Плавный переход от splash к основному интерфейсу без мигания.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ✅ Единый источник правды для auth
|
||||||
|
|
||||||
|
**Проблема:** `TinodeClient.isAuthenticated` и `SessionRepository.authState` создавали дублирование и путаницу.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
### Изменения в TinodeClient
|
||||||
|
```kotlin
|
||||||
|
// Было: публичный метод
|
||||||
|
fun isAuthenticated(): Boolean = httpClient.isAuthenticated
|
||||||
|
|
||||||
|
// Стало: приватный метод (для внутренней логики)
|
||||||
|
private fun isAuthenticated(): Boolean = httpClient.isAuthenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionRepository — единственный источник правды
|
||||||
|
```kotlin
|
||||||
|
sealed class AuthState {
|
||||||
|
data object Unauthenticated : AuthState()
|
||||||
|
data class Authenticated(val uid: String) : AuthState()
|
||||||
|
data object SessionExpired : AuthState()
|
||||||
|
}
|
||||||
|
|
||||||
|
val authState: StateFlow<AuthState>
|
||||||
|
val isAuthenticated: Boolean // proxy-свойство
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChatRepository делегирует SessionRepository
|
||||||
|
```kotlin
|
||||||
|
fun isAuthenticated(): Boolean = sessionRepository.isAuthenticated
|
||||||
|
val authState: Flow<AuthState> = sessionRepository.authState
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:** Чёткая иерархия — `SessionRepository` → `ChatRepository` → UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ✅ Retry logic с exponential backoff
|
||||||
|
|
||||||
|
**Проблема:** Сетевые операции не повторялись при временных сбоях.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
### Создан утилитарный класс
|
||||||
|
`util/RetryWithBackoff.kt`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Предустановленные политики
|
||||||
|
```kotlin
|
||||||
|
RetryPolicy.Quick // 2 retry, 500ms-2s (для auth)
|
||||||
|
RetryPolicy.Network // 3 retry, 1s-10s (для сети)
|
||||||
|
RetryPolicy.Conservative // 5 retry, 2s-30s (для критичных операций)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Интеграция в SessionRepository
|
||||||
|
```kotlin
|
||||||
|
suspend fun login(username: String, password: String): Result<Unit> {
|
||||||
|
return RetryPolicy.Quick {
|
||||||
|
tinodeClient.login(username, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun autoLogin(): Result<Unit> {
|
||||||
|
return RetryPolicy.Network(
|
||||||
|
shouldRetry = { e ->
|
||||||
|
// Не retry если токен недействителен
|
||||||
|
e.message?.contains("401") != true
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
tinodeClient.autoLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:** Устойчивость к временным сбоям сети, автоматические повторные попытки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ✅ Тесты для SessionRepository
|
||||||
|
|
||||||
|
**Созданные файлы:**
|
||||||
|
|
||||||
|
### SessionRepositoryTest.kt (13 тестов)
|
||||||
|
- `initial state is Unauthenticated`
|
||||||
|
- `login success updates auth state`
|
||||||
|
- `login failure keeps Unauthenticated`
|
||||||
|
- `register success updates auth state`
|
||||||
|
- `register failure keeps Unauthenticated`
|
||||||
|
- `logout sets state to Unauthenticated`
|
||||||
|
- `autoLogin success updates auth state`
|
||||||
|
- `autoLogin failure keeps Unauthenticated`
|
||||||
|
- `isAuthenticated returns correct value`
|
||||||
|
- `myUid returns current uid`
|
||||||
|
- `connectionState delegates to tinodeClient`
|
||||||
|
- `registerWithFullProfile success`
|
||||||
|
- `hasSavedToken returns true when authenticated`
|
||||||
|
|
||||||
|
### RetryWithBackoffTest.kt (10 тестов)
|
||||||
|
- `success on first attempt`
|
||||||
|
- `retries on failure and eventually succeeds`
|
||||||
|
- `fails after max retries exhausted`
|
||||||
|
- `shouldRetry predicate controls retry`
|
||||||
|
- `RetryPolicy Quick/Network/Conservative`
|
||||||
|
- `RetryPolicy invoke operator`
|
||||||
|
- `exponential backoff increases delay`
|
||||||
|
- `maxDelayMs caps the delay`
|
||||||
|
|
||||||
|
**Итого:** 23 новых теста + существующие 34 = **57 тестов**
|
||||||
|
|
||||||
|
**Команда запуска:**
|
||||||
|
```bash
|
||||||
|
./gradlew :app:testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ✅ Логирование с Timber
|
||||||
|
|
||||||
|
**Проблема:** Использование `println` и `android.util.Log` без единой стратегии.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
### Добавлена зависимость
|
||||||
|
```kotlin
|
||||||
|
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Инициализация в LastochkaApp
|
||||||
|
```kotlin
|
||||||
|
override fun onCreate() {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
|
} else {
|
||||||
|
// Production: Timber.plant(CrashlyticsTree())
|
||||||
|
}
|
||||||
|
Timber.d("LastochkaApp onCreate")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Замена в TinodeHttpClient
|
||||||
|
```kotlin
|
||||||
|
// Было:
|
||||||
|
e.printStackTrace()
|
||||||
|
println("WebSocket onFailure: ...")
|
||||||
|
|
||||||
|
// Стало:
|
||||||
|
Timber.e(e, "Failed to parse WebSocket message")
|
||||||
|
Timber.e(t, "WebSocket onFailure: code=$statusCode, message=$errorMsg")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Замена в TinodeClient
|
||||||
|
```kotlin
|
||||||
|
Timber.d("Connecting to Tinode server...")
|
||||||
|
Timber.d("WebSocket connected")
|
||||||
|
Timber.e("WebSocket connection error")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Замена в SessionRepository
|
||||||
|
```kotlin
|
||||||
|
// Было:
|
||||||
|
android.util.Log.d("SessionRepository", "login result: $result")
|
||||||
|
|
||||||
|
// Стало:
|
||||||
|
Timber.d("Attempting login for user: $username")
|
||||||
|
Timber.d("Login result: $result, myUid=${tinodeClient.myUid}")
|
||||||
|
Timber.w("Login failed: ${result.exceptionOrNull()?.message}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:** Структурированное логирование с тегами, автоматическое отключение в production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изменённые файлы
|
||||||
|
|
||||||
|
| Файл | Изменения |
|
||||||
|
|---|---|
|
||||||
|
| `LastochkaApp.kt` | +Timber, убран `connect()` |
|
||||||
|
| `MainActivity.kt` | +SplashScreen API |
|
||||||
|
| `SessionRepository.kt` | +Timber, +Retry, +autoLogin в init |
|
||||||
|
| `TinodeClient.kt` | +Timber, -публичный isAuthenticated, +connect в autoLogin |
|
||||||
|
| `TinodeHttpClient.kt` | +Timber |
|
||||||
|
| `ChatRepository.kt` | Без изменений (уже делегировал) |
|
||||||
|
| `build.gradle.kts` | +core-splashscreen, +timber |
|
||||||
|
| `AndroidManifest.xml` | Theme.App.Starting |
|
||||||
|
| `themes.xml` | Theme.App.Starting |
|
||||||
|
| `themes.xml (night)` | Ночная тема для splash |
|
||||||
|
|
||||||
|
## Новые файлы
|
||||||
|
|
||||||
|
| Файл | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `util/RetryWithBackoff.kt` | Утилита retry с exponential backoff |
|
||||||
|
| `data/SessionRepositoryTest.kt` | 13 тестов для SessionRepository |
|
||||||
|
| `util/RetryWithBackoffTest.kt` | 10 тестов для retry утилиты |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ UI Layer │
|
||||||
|
│ MainActivity (SplashScreen + NavHost) │
|
||||||
|
│ LoginScreen → AuthViewModel │
|
||||||
|
│ ChatListScreen → ChatListViewModel │
|
||||||
|
│ ChatScreen → ChatViewModel │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Domain / ViewModel Layer │
|
||||||
|
│ AuthViewModel ──┐ │
|
||||||
|
│ ChatListViewModel┼──> ChatRepository │
|
||||||
|
│ ChatViewModel ──┘ ↓ │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Data Layer │
|
||||||
|
│ SessionRepository (единственный auth source) │
|
||||||
|
│ ├─ RetryPolicy (автоматические retry) │
|
||||||
|
│ ├─ DataStore (persist UID) │
|
||||||
|
│ └─ TinodeClient │
|
||||||
|
│ ├─ TinodeHttpClient (WebSocket) │
|
||||||
|
│ └─ Timber (логирование) │
|
||||||
|
│ ChatRepository (делегирует SessionRepository) │
|
||||||
|
│ AppDatabase (Room кэш) │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Infrastructure │
|
||||||
|
│ NetworkMonitor (автореконнект) │
|
||||||
|
│ FCM Service (push-уведомления) │
|
||||||
|
│ Timber DebugTree (dev) / CrashlyticsTree (prod) │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поток авторизации (исправленный)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. LastochkaApp.onCreate()
|
||||||
|
└─> Plant Timber (debug only)
|
||||||
|
└─> Инициализация TinodeClient (без подключения)
|
||||||
|
|
||||||
|
2. SessionRepository init (@Inject)
|
||||||
|
└─> loadUid() → есть UID?
|
||||||
|
└─> autoLogin() с RetryPolicy.Network
|
||||||
|
|
||||||
|
3. TinodeClient.autoLogin()
|
||||||
|
└─> connect() если не подключён
|
||||||
|
└─> awaitConnection()
|
||||||
|
└─> httpClient.loginToken(token)
|
||||||
|
|
||||||
|
4. SessionRepository._authState
|
||||||
|
└─> Authenticated(uid) при успехе
|
||||||
|
└─> Unauthenticated при неудаче
|
||||||
|
|
||||||
|
5. MainActivity
|
||||||
|
└─> SplashScreen ждёт authState
|
||||||
|
└─> Authenticated → MainAppScreen
|
||||||
|
└─> Unauthenticated → LoginScreen
|
||||||
|
|
||||||
|
✨ Без мигания!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендации для дальнейшего улучшения
|
||||||
|
|
||||||
|
1. **Добавить CrashlyticsTree** для production сбоев
|
||||||
|
2. **Интеграция с FCM** для push-уведомлений
|
||||||
|
3. **EncryptedSharedPreferences** для хранения токена
|
||||||
|
4. **Прогулка по графу навигации** — добавить deep links
|
||||||
|
5. **Compose Previews** для всех UI компонентов
|
||||||
|
6. **Benchmark тесты** для проверки производительности
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды для проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка
|
||||||
|
./gradlew :app:assembleDebug
|
||||||
|
|
||||||
|
# Тесты
|
||||||
|
./gradlew :app:testDebugUnitTest
|
||||||
|
|
||||||
|
# Проверка компиляции
|
||||||
|
./gradlew :app:compileDebugKotlin
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
./gradlew :app:lintDebug
|
||||||
|
```
|
||||||
46
lastochka-android-compose/CHANGELOG.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Replaced tinodesdk with custom HTTP client
|
||||||
|
- Removed `:tinodesdk` module from project
|
||||||
|
- Created `TinodeHttpClient` (OkHttp WebSocket + JSON/Gson)
|
||||||
|
- Created `TinodeProtocol` data models (hi, login, acc, sub, pub, data, meta, pres, info, ctrl)
|
||||||
|
- Rewrote `TinodeClient` as high-level wrapper for UI
|
||||||
|
- Removed tinodesdk dependencies: Jackson, ICU4J, Java-WebSocket
|
||||||
|
|
||||||
|
### Migrated from kapt to KSP
|
||||||
|
- Replaced `org.jetbrains.kotlin.kapt` with `com.google.devtools.ksp` (1.9.25-1.0.20)
|
||||||
|
- Hilt compiler: `kapt` → `ksp`
|
||||||
|
- Room compiler: `kapt` → `ksp`
|
||||||
|
- Kotlin version: 1.9.24 → 1.9.25
|
||||||
|
- Compose Compiler: 1.5.14 → 1.5.15
|
||||||
|
|
||||||
|
### Added OkHttp for WebSocket communication
|
||||||
|
- Added `com.squareup.okhttp3:okhttp:4.12.0`
|
||||||
|
|
||||||
|
### Updated app icon
|
||||||
|
- Replaced all `ic_launcher_foreground.png` with `logo2.png` (mdpi–xxxhdpi)
|
||||||
|
- Replaced `ic_launcher_play_store.png` (512×512)
|
||||||
|
- Updated splash screen: SVG → PNG (`logo_src.png`)
|
||||||
|
- Added `values-night/colors.xml` for dark mode splash
|
||||||
|
- Removed old `logo_splash.xml` (SVG-based)
|
||||||
|
|
||||||
|
### Fixed Compose import issues
|
||||||
|
- Added missing `sp`/`dp` imports in LoginScreen, ChatScreen, ChatListScreen, Avatar
|
||||||
|
- Fixed `Done`/`DoneAll` icons: `AutoMirrored` → `filled` (with material-icons-extended)
|
||||||
|
- Added `verticalScroll`/`rememberScrollState` imports in RegisterScreen
|
||||||
|
- Fixed `LockReset` → `Lock` icon
|
||||||
|
- Fixed Avatar fontSize: `size.value * 0.38f.sp` (was broken `.dp`)
|
||||||
|
- Added `@OptIn(ExperimentalMaterial3Api)` for RegisterScreen
|
||||||
|
|
||||||
|
### Fixed dependency issues
|
||||||
|
- Added `com.google.dagger:hilt.android` Gradle plugin (was missing)
|
||||||
|
- Added `com.google.android.material:material:1.12.0` (needed for themes)
|
||||||
|
- Added `com.google.devtools.ksp` plugin
|
||||||
|
- Removed `com.android.library` root plugin (tinodesdk module removed)
|
||||||
|
|
||||||
|
### Code cleanup
|
||||||
|
- Removed `TinodeConnState` enum conflict with kotlinx.coroutines
|
||||||
|
- Used callback-based state observer instead of broken Flow API
|
||||||
|
- Simplified `TinodeClient` event handling with `runBlocking` + `emit`
|
||||||
137
lastochka-android-compose/README.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Lastochka Android (Compose)
|
||||||
|
|
||||||
|
Мессенджер «Ласточка» для Android — **Jetpack Compose** + собственный Tinode HTTP-клиент.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
MVVM + Repository pattern
|
||||||
|
|
||||||
|
UI (Compose) → ViewModel → Repository → TinodeHttpClient (OkHttp WebSocket) + Room DB
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/ru/lastochka/messenger/
|
||||||
|
├── LastochkaApp.kt — Application, инициализация
|
||||||
|
├── MainActivity.kt — Точка входа, навигация
|
||||||
|
├── data/
|
||||||
|
│ ├── TinodeClient.kt — Высокоуровневый клиент (сессия + auth)
|
||||||
|
│ ├── TinodeHttpClient.kt — Низкоуровневый WebSocket-клиент (OkHttp)
|
||||||
|
│ ├── ChatRepository.kt — Repository
|
||||||
|
│ ├── local/
|
||||||
|
│ │ └── AppDatabase.kt — Room DB
|
||||||
|
│ └── model/
|
||||||
|
│ └── TinodeProtocol.kt — Модели Tinode-протокола
|
||||||
|
├── viewmodel/
|
||||||
|
│ ├── AuthViewModel.kt — Вход/регистрация
|
||||||
|
│ ├── ChatListViewModel.kt — Список чатов
|
||||||
|
│ └── ChatViewModel.kt — Экран чата
|
||||||
|
├── navigation/
|
||||||
|
│ └── Screen.kt — Маршруты навигации
|
||||||
|
├── di/
|
||||||
|
│ └── AppModule.kt — Hilt модули
|
||||||
|
└── ui/
|
||||||
|
├── screens/
|
||||||
|
│ ├── auth/ — LoginScreen, RegisterScreen
|
||||||
|
│ ├── chat/ — ChatScreen
|
||||||
|
│ └── chatlist/ — ChatListScreen
|
||||||
|
├── components/ — Avatar, ChatItem, MessageBubble, ...
|
||||||
|
└── theme/ — Color, Theme, Type
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|-----------|
|
||||||
|
| **UI** | Jetpack Compose + Material 3 |
|
||||||
|
| **DI** | Hilt 2.52 + KSP |
|
||||||
|
| **DB** | Room 2.6.1 + KSP |
|
||||||
|
| **Networking** | OkHttp 4.12.0 (WebSocket) |
|
||||||
|
| **Serialization** | Gson 2.11.0 |
|
||||||
|
| **Async** | Kotlin Coroutines + Flow |
|
||||||
|
| **Build** | Gradle 8.7, Kotlin 1.9.25 |
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- JDK 17+ (Android Studio JDK или системная)
|
||||||
|
- Android SDK 35 (compileSdk), minSdk 26
|
||||||
|
- Gradle 8.7
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
set JAVA_HOME=C:\Program Files\Android\Android Studio\jbr
|
||||||
|
set PATH=%JAVA_HOME%\bin;%PATH%
|
||||||
|
cd dev\lastochka-android-compose
|
||||||
|
gradlew.bat assembleDebug
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
export JAVA_HOME=/path/to/android-studio/jbr
|
||||||
|
cd dev/lastochka-android-compose
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: `app/build/outputs/apk/debug/app-debug.apk` (~22 MB)
|
||||||
|
|
||||||
|
### Запуск на эмуляторе
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
adb shell am start -n ru.lastochka.messenger/.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация сервера
|
||||||
|
|
||||||
|
Сервер настраивается в `app/build.gradle.kts`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
debug {
|
||||||
|
resValue("string", "default_host_name", "app.lastochka-m.ru")
|
||||||
|
resValue("string", "default_api_key", "AQEAAAABAAD_...")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для локальной разработки измените `hostName` на `localhost:6060` и `useTLS = false`.
|
||||||
|
|
||||||
|
## Протокол
|
||||||
|
|
||||||
|
Приложение использует **Tinode** протокол поверх WebSocket:
|
||||||
|
|
||||||
|
- **Подключение:** `wss://host/ws` + header `X-Tinode-APIKey`
|
||||||
|
- **Аутентификация:** `basic` (логин/пароль) или `token`
|
||||||
|
- **Сообщения:** plain text `{"txt": "..."}` (Drafty пока не поддерживается)
|
||||||
|
|
||||||
|
Подробная документация: [docs/MIGRATION.md](docs/MIGRATION.md)
|
||||||
|
|
||||||
|
## Иконка приложения
|
||||||
|
|
||||||
|
- **Источник:** `Brand/logo2.png` (ласточка, градиент blue→purple)
|
||||||
|
- **Формат:** Adaptive Icons (Android 8+)
|
||||||
|
- **Плотности:** mdpi (48px), hdpi (72px), xhdpi (96px), xxhdpi (144px), xxxhdpi (192px)
|
||||||
|
- **Play Store:** 512×512px
|
||||||
|
- **Splash:** PNG в 5 плотностях (120–480px)
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- Только текстовые сообщения (нет Drafty, файлов, изображений)
|
||||||
|
- Нет push-уведомлений
|
||||||
|
- Нет видеозвонков
|
||||||
|
- Нет E2E шифрования
|
||||||
|
|
||||||
|
## Планы
|
||||||
|
|
||||||
|
- [ ] Drafty парсер для форматированных сообщений
|
||||||
|
- [ ] Загрузка файлов/изображений
|
||||||
|
- [ ] Push-уведомления (Firebase)
|
||||||
|
- [ ] Offline-кэш сообщений
|
||||||
|
- [ ] Видеозвонки (WebRTC)
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Apache-2.0 (форк HuLa + Tinode)
|
||||||
|
|
||||||
|
© 2026 Ласточка
|
||||||
182
lastochka-android-compose/STATUS.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Ласточка Android (Compose) — Статус разработки
|
||||||
|
|
||||||
|
> Новый Android-клиент, переписанный с нуля на Jetpack Compose.
|
||||||
|
> Заменяет старый Tindroid-форк (`dev/lastochka-android/`).
|
||||||
|
|
||||||
|
**Путь проекта:** `dev/lastochka-android-compose/`
|
||||||
|
**Последнее обновление:** 2026-04-03
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Сделано
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- [x] Создана папка проекта `dev/lastochka-android-compose/`
|
||||||
|
- [x] Gradle wrapper (8.7, совместим с AGP 8.3.2)
|
||||||
|
- [x] `settings.gradle.kts`, `build.gradle.kts` (корневой + app + tinodesdk)
|
||||||
|
- [x] `gradle.properties`, `local.properties` (Android SDK настроен)
|
||||||
|
- [x] ProGuard правила
|
||||||
|
|
||||||
|
### Tinode SDK
|
||||||
|
- [x] Скопирован модуль `tinodesdk/` из старого проекта (69 Java-файлов)
|
||||||
|
- [x] `build.gradle.kts` для SDK модуля
|
||||||
|
- [x] `consumer-rules.pro`, `proguard-rules.pro`
|
||||||
|
|
||||||
|
### Слой данных
|
||||||
|
- [x] `TinodeClient.kt` — обёртка над Tinode SDK (login, register, subscribe, sendMessage, events)
|
||||||
|
- [x] `ChatRepository.kt` — Repository pattern (Tinode + Room)
|
||||||
|
- [x] `AppDatabase.kt` — Room DB: `MessageEntity`, `ContactEntity`, `TypingEntity` + DAOs
|
||||||
|
|
||||||
|
### ViewModel
|
||||||
|
- [x] `AuthViewModel.kt` — вход, регистрация, проверка username, автологин
|
||||||
|
- [x] `ChatListViewModel.kt` — загрузка контактов из MeTopic, обновление через события
|
||||||
|
- [x] `ChatViewModel.kt` — сообщения чата, отправка, typing, read receipts
|
||||||
|
|
||||||
|
### Навигация
|
||||||
|
- [x] `Screen.kt` — маршруты: Login, Register, ChatList, Chat, Profile, NewChat
|
||||||
|
- [x] `MainActivity.kt` — NavHost с проверкой авторизации
|
||||||
|
|
||||||
|
### UI — Тема (lastochka-ui стиль)
|
||||||
|
- [x] `Color.kt` — 40+ цветов (бренд, light/dark, bubble, статусы, аватары)
|
||||||
|
- [x] `Type.kt` — типографика (display, headline, title, body, label)
|
||||||
|
- [x] `Theme.kt` — Material 3 + LocalBubbleColors для light/dark
|
||||||
|
|
||||||
|
### UI — Компоненты
|
||||||
|
- [x] `Avatar.kt` — аватар с инициалами, цветовым хешем (16 цветов), online-индикатор
|
||||||
|
- [x] `ChatItem.kt` — элемент списка чатов (аватар, имя, превью, время, badge unread, muted)
|
||||||
|
- [x] `MessageBubble.kt` — пузырь сообщения (скругление 18px, хвостик, статус ✓/✓✓, разделитель дат)
|
||||||
|
- [x] `MessageInput.kt` — поле ввода (скрепка, текст, кнопка отправки/микрофон)
|
||||||
|
- [x] `ChatHeader.kt` — хедер чата (назад, аватар, имя, статус, звонок, видео, меню)
|
||||||
|
|
||||||
|
### UI — Экраны
|
||||||
|
- [x] `LoginScreen.kt` — вход (логотип, username, пароль, show/hide, error, ссылка на регистрацию)
|
||||||
|
- [x] `RegisterScreen.kt` — регистрация (имя, username с проверкой, пароль, подтверждение)
|
||||||
|
- [x] `ChatListScreen.kt` — список чатов (Empty state, LazyColumn, FAB, error snackbar)
|
||||||
|
- [x] `ChatScreen.kt` — экран чата (ChatHeader, LazyColumn с bubble, DateDivider, MessageInput)
|
||||||
|
|
||||||
|
### DI
|
||||||
|
- [x] `AppModule.kt` — Hilt: Database, TinodeClient, Repository
|
||||||
|
- [x] `@HiltAndroidApp` на `LastochkaApp`
|
||||||
|
- [x] `@AndroidEntryPoint` на `MainActivity`
|
||||||
|
- [x] `@HiltViewModel` на все 3 ViewModel
|
||||||
|
|
||||||
|
### Ресурсы
|
||||||
|
- [x] `colors.xml` — все цвета (light/dark/bubble/status)
|
||||||
|
- [x] `strings.xml` — 40+ строк (RU)
|
||||||
|
- [x] `themes.xml` — LaunchScreen + AppTheme
|
||||||
|
- [x] `AndroidManifest.xml` — permissions, activity, splash theme
|
||||||
|
- [x] mipmap иконки (mdpi–xxxhdpi) — логотип Ласточки
|
||||||
|
- [x] `ic_launcher.xml` / `ic_launcher_round.xml` — adaptive icon
|
||||||
|
- [x] `splash_screen.xml` — splash с логотипом
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
- [x] `README.md` — архитектура, стек, цвета, сборка, roadmap
|
||||||
|
|
||||||
|
### Итого: **22 Kotlin файла**, ~2500 строк кода
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 В процессе / предстоит
|
||||||
|
|
||||||
|
### Критично для MVP
|
||||||
|
- [ ] **Собрать проект** — проверить компиляцию (`gradlew assembleDebug`) — **ГОТОВО К СБОРКЕ после перезагрузки консоли**
|
||||||
|
- [ ] **Сохранение/восстановление токена** — `autoLogin()` должен работать при перезапуске
|
||||||
|
|
||||||
|
### Чат (базовый)
|
||||||
|
- [ ] **Загрузка истории сообщений** — подгрузка старых сообщений при скролле вверх
|
||||||
|
- [ ] **Отправка Drafty** — сейчас отправляется plain text, нужен полноценный Drafty
|
||||||
|
- [ ] **Отображение senderName** — резолвить имя отправителя из контактов
|
||||||
|
- [ ] **Реакция на входящие сообщения** — обновление UI через Tinode events
|
||||||
|
|
||||||
|
### Чат (продвинутый)
|
||||||
|
- [ ] **Голосовые сообщения** — запись, воспроизведение, waveform
|
||||||
|
- [ ] **Файловые вложения** — фото, видео, документы (CameraX, MediaStore)
|
||||||
|
- [ ] **Редактирование/удаление** сообщений
|
||||||
|
- [ ] **Пересылка** сообщений
|
||||||
|
- [ ] **Reply** на сообщение
|
||||||
|
- [ ] **Поиск** по сообщениям
|
||||||
|
|
||||||
|
### Группы и каналы
|
||||||
|
- [ ] **Создание группы** — выбор участников, название, аватар
|
||||||
|
- [ ] **Групповой чат** — отображение имён отправителей
|
||||||
|
- [ ] **Каналы** — read-only подписчики
|
||||||
|
|
||||||
|
### Звонки
|
||||||
|
- [ ] **WebRTC** — аудио/видео звонки (из старого Tindroid)
|
||||||
|
- [ ] **Push-уведомления** — FCM для входящих звонков и сообщений
|
||||||
|
|
||||||
|
### Настройки
|
||||||
|
- [ ] **Экран профиля** — имя, аватар, смена пароля
|
||||||
|
- [ ] **Настройки уведомлений** — звук, вибрация, мут
|
||||||
|
- [ ] **Тема** — переключение light/dark/system
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- [ ] **Push-уведомления FCM** — фоновые уведомления
|
||||||
|
- [ ] **Фоновая синхронизация** — WorkManager
|
||||||
|
- [ ] **Обработка offline** — очередь отправки, кеш
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Зависимости
|
||||||
|
|
||||||
|
| Библиотека | Версия |
|
||||||
|
|------------|--------|
|
||||||
|
| Kotlin | 1.9.24 |
|
||||||
|
| Compose BOM | 2024.06.00 |
|
||||||
|
| Material 3 | (из BOM) |
|
||||||
|
| Room | 2.6.1 |
|
||||||
|
| Hilt | 2.52 |
|
||||||
|
| Navigation Compose | 2.8.3 |
|
||||||
|
| Coil | 2.7.0 |
|
||||||
|
| Coroutines | 1.8.1 |
|
||||||
|
| DataStore | 1.1.1 |
|
||||||
|
| Gson | 2.11.0 |
|
||||||
|
| Tinode SDK | форк Tinode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dev/lastochka-android-compose
|
||||||
|
|
||||||
|
# Сборка (Windows)
|
||||||
|
gradlew.bat assembleDebug
|
||||||
|
gradlew.bat assembleRelease
|
||||||
|
|
||||||
|
# Установка на устройство
|
||||||
|
gradlew.bat installDebug
|
||||||
|
|
||||||
|
# Запуск
|
||||||
|
gradlew.bat installDebug & adb shell am start -n ru.lastochka.messenger/.MainActivity
|
||||||
|
|
||||||
|
# Лог
|
||||||
|
adb logcat -s Lastochka Tinode
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Дизайн-соответствие с lastochka-ui
|
||||||
|
|
||||||
|
| lastochka-ui | Android Compose | Статус |
|
||||||
|
|--------------|-----------------|--------|
|
||||||
|
| Bubble свои `#EEF2FF` | `BubbleOwn` | ✅ |
|
||||||
|
| Bubble чужие `#FFFFFF` | `BubblePeer` | ✅ |
|
||||||
|
| Скругление 18px | `RoundedCornerShape(18.dp)` | ✅ |
|
||||||
|
| Хвостик bubble | `bottomEnd=4dp` / `bottomStart=4dp` | ✅ |
|
||||||
|
| Статус ✓/✓✓ | `Done` / `DoneAll` icons | ✅ |
|
||||||
|
| Аватар + инициалы | `Avatar` composable | ✅ |
|
||||||
|
| Разделитель дат | `DateDivider` | ✅ |
|
||||||
|
| Input скруглённый | `MessageInput` | ✅ |
|
||||||
|
| Тёмная тема | `LastochkaTheme(darkTheme)` | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Примечания
|
||||||
|
|
||||||
|
1. **Windows** — используйте `gradlew.bat` вместо `./gradlew`
|
||||||
|
2. **Tinode SDK** — Java-код, работает через PromisedReply (async pattern), нужно адаптировать под Kotlin coroutines
|
||||||
|
3. **Room** — используется Flow для реактивных обновлений UI
|
||||||
|
4. **Drafty** — формат rich-контента Tinode, пока отправляется plain text
|
||||||
|
5. **Оффлайн** — Room кеш + Tinode SDK LocalData
|
||||||
|
6. **Hilt** — полностью настроен (@HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel)
|
||||||
143
lastochka-android-compose/app/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
26
lastochka-android-compose/app/proguard-rules.pro
vendored
Normal 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>;
|
||||||
|
}
|
||||||
54
lastochka-android-compose/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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 = "Ласточка"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 не зарегистрирован — игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: Реализация удаления чата
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 163 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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%" />
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Splash screen (dark mode) -->
|
||||||
|
<color name="colorSplashScreenBackground">#3B3E7F</color>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
51
lastochka-android-compose/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
25
lastochka-android-compose/app/src/main/res/values/themes.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 контакты должны идти первыми в списке
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||