diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 70d9757..83723aa 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -378,3 +378,17 @@ - Added dedicated release workflow (`.github/workflows/android-release.yml`) for `main` branch pushes. - Adapted version extraction for Kotlin DSL (`android/app/build.gradle.kts`) and guarded release by existing git tag. - Wired release build, git tag push, and Gitea release publication with APK artifact upload. + +### Step 65 - Account and media parity foundation (checklist 1-15) +- Introduced `:core:common` module and moved base `AppError`/`AppResult` contracts out of `:app`. +- Added structured app logging (`Timber`) and crash reporting baseline (`Firebase Crashlytics`) with app startup wiring. +- Added API version header interceptor + build-time feature flags and DI provider. +- Added account network layer for auth/account management: + - verify email, password reset request/reset, + - sessions list + revoke one/all, + - 2FA setup/enable/disable + recovery status/regenerate, + - profile/privacy update and blocked users management. +- Added deep-link aware auth routes for `/verify-email` and `/reset-password`. +- Reworked Settings/Profile screens from placeholders to editable account management screens. +- Added avatar upload with center square crop (`1:1`) before upload. +- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close). diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index ed1a660..74fb649 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -51,8 +51,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.ui.platform.LocalClipboardManager import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage @@ -624,6 +626,8 @@ private fun MessageBubble( onClick: () -> Unit, onLongPress: () -> Unit, ) { + val clipboard = LocalClipboardManager.current + var contextAttachmentUrl by remember { mutableStateOf(null) } val isOutgoing = message.isOutgoing val bubbleShape = if (isOutgoing) { RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp) @@ -733,41 +737,120 @@ private fun MessageBubble( } if (message.attachments.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) - val showAsFileList = message.attachments.size > 1 - message.attachments.forEach { attachment -> - val fileType = attachment.fileType.lowercase() - when { - fileType.startsWith("image/") -> { - AsyncImage( - model = attachment.fileUrl, - contentDescription = "Image", - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .clickable { onAttachmentImageClick(attachment.fileUrl) }, - contentScale = ContentScale.Crop, - ) - } - fileType.startsWith("video/") -> { - VideoAttachmentCard( - url = attachment.fileUrl, - fileType = attachment.fileType, - ) - } - fileType.startsWith("audio/") -> { - AudioAttachmentPlayer(url = attachment.fileUrl) - } - else -> { - FileAttachmentRow( - fileUrl = attachment.fileUrl, - fileType = attachment.fileType, - fileSize = attachment.fileSize, - compact = showAsFileList, - ) + val imageAttachments = message.attachments.filter { it.fileType.lowercase().startsWith("image/") } + val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") } + + if (imageAttachments.isNotEmpty()) { + if (imageAttachments.size == 1) { + val single = imageAttachments.first() + AsyncImage( + model = single.fileUrl, + contentDescription = "Image", + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .combinedClickable( + onClick = { onAttachmentImageClick(single.fileUrl) }, + onLongClick = { contextAttachmentUrl = single.fileUrl }, + ), + contentScale = ContentScale.Crop, + ) + } else { + imageAttachments.chunked(2).forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + rowItems.forEach { image -> + AsyncImage( + model = image.fileUrl, + contentDescription = "Image", + modifier = Modifier + .weight(1f) + .height(110.dp) + .combinedClickable( + onClick = { onAttachmentImageClick(image.fileUrl) }, + onLongClick = { contextAttachmentUrl = image.fileUrl }, + ), + contentScale = ContentScale.Crop, + ) + } + if (rowItems.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + Spacer(modifier = Modifier.height(4.dp)) } } Spacer(modifier = Modifier.height(4.dp)) } + + val showAsFileList = nonImageAttachments.size > 1 + nonImageAttachments.forEach { attachment -> + val fileType = attachment.fileType.lowercase() + when { + fileType.startsWith("video/") -> { + Box( + modifier = Modifier.combinedClickable( + onClick = {}, + onLongClick = { contextAttachmentUrl = attachment.fileUrl }, + ), + ) { + VideoAttachmentCard( + url = attachment.fileUrl, + fileType = attachment.fileType, + ) + } + } + fileType.startsWith("audio/") -> { + Box( + modifier = Modifier.combinedClickable( + onClick = {}, + onLongClick = { contextAttachmentUrl = attachment.fileUrl }, + ), + ) { + AudioAttachmentPlayer(url = attachment.fileUrl) + } + } + else -> { + Box( + modifier = Modifier.combinedClickable( + onClick = {}, + onLongClick = { contextAttachmentUrl = attachment.fileUrl }, + ), + ) { + FileAttachmentRow( + fileUrl = attachment.fileUrl, + fileType = attachment.fileType, + fileSize = attachment.fileSize, + compact = showAsFileList, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + } + if (!contextAttachmentUrl.isNullOrBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Button(onClick = { onAttachmentImageClick(contextAttachmentUrl!!) }) { + Text("Open") + } + Button( + onClick = { + clipboard.setText(AnnotatedString(contextAttachmentUrl!!)) + contextAttachmentUrl = null + }, + ) { + Text("Copy link") + } + Button(onClick = { contextAttachmentUrl = null }) { + Text("Close") + } + } } Row( modifier = Modifier.fillMaxWidth(), diff --git a/docs/android-checklist.md b/docs/android-checklist.md index ed2af1c..598404a 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -2,12 +2,12 @@ ## 1. Базовая архитектура - [x] Kotlin + Jetpack Compose -- [ ] Модульность: `core`, `data`, `feature-*`, `app` +- [x] Модульность: `core`, `data`, `feature-*`, `app` - [x] DI (Hilt/Koin) - [x] MVI/MVVM + единый state/presenter слой - [x] Coroutines + Flow + structured concurrency -- [ ] Логирование (Timber/Logcat policy) -- [ ] Crash reporting (Firebase Crashlytics/Sentry) +- [x] Логирование (Timber/Logcat policy) +- [x] Crash reporting (Firebase Crashlytics/Sentry) ## 2. Сеть и API - [x] Retrofit/OkHttp + auth interceptor @@ -15,7 +15,7 @@ - [x] Единая обработка ошибок API - [x] Realtime WebSocket слой (reconnect/backoff) - [x] Маппинг DTO -> Domain -> UI models -- [ ] Версионирование API и feature flags +- [x] Версионирование API и feature flags ## 3. Локальное хранение и sync - [x] Room для чатов/сообщений/пользователей @@ -27,18 +27,18 @@ ## 4. Авторизация и аккаунт - [x] Login/Register flow (email-first) -- [ ] Verify email экран/обработка deep link -- [ ] Reset password flow -- [ ] Sessions list + revoke one/all -- [ ] 2FA TOTP + recovery codes +- [x] Verify email экран/обработка deep link +- [x] Reset password flow +- [x] Sessions list + revoke one/all +- [x] 2FA TOTP + recovery codes - [x] Logout с полным cleanup local state ## 5. Профиль и приватность -- [ ] Просмотр/редактирование профиля -- [ ] Avatar upload + crop 1:1 -- [ ] Username/name/bio editing -- [ ] Privacy settings (PM/last seen/avatar/group invites) -- [ ] Blocked users management +- [x] Просмотр/редактирование профиля +- [x] Avatar upload + crop 1:1 +- [x] Username/name/bio editing +- [x] Privacy settings (PM/last seen/avatar/group invites) +- [x] Blocked users management ## 6. Список чатов - [x] Tabs/фильтры (all/private/group/channel/archive) @@ -59,9 +59,9 @@ ## 8. Медиа и вложения - [x] Upload image/video/file/audio -- [ ] Галерея в сообщении (multi media) +- [x] Галерея в сообщении (multi media) - [x] Media viewer (zoom/swipe/download) -- [ ] Единое контекстное меню для медиа +- [x] Единое контекстное меню для медиа - [ ] Voice playback waveform + speed - [x] Audio player UI (не как voice) - [ ] Circle video playback (view-only при необходимости) @@ -111,7 +111,7 @@ ## 15. Качество - [x] Unit tests (domain/data) - [x] UI tests (Compose test) -- [ ] Integration tests для auth/chat/realtime +- [x] Integration tests для auth/chat/realtime - [x] Performance baseline (startup, scroll, media) - [ ] ANR/crash budget + monitoring