android: add media gallery and account checklist progress
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:05:52 +03:00
parent 5368515112
commit f708854bb2
3 changed files with 144 additions and 47 deletions

View File

@@ -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).

View File

@@ -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<String?>(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(),

View File

@@ -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