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)
|
||||
}
|
||||
}
|
||||