android: add media gallery and account checklist progress
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user