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.
|
- 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.
|
- 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.
|
- 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.unit.dp
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
@@ -624,6 +626,8 @@ private fun MessageBubble(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
var contextAttachmentUrl by remember { mutableStateOf<String?>(null) }
|
||||||
val isOutgoing = message.isOutgoing
|
val isOutgoing = message.isOutgoing
|
||||||
val bubbleShape = if (isOutgoing) {
|
val bubbleShape = if (isOutgoing) {
|
||||||
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp)
|
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp)
|
||||||
@@ -733,41 +737,120 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
if (message.attachments.isNotEmpty()) {
|
if (message.attachments.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
val showAsFileList = message.attachments.size > 1
|
val imageAttachments = message.attachments.filter { it.fileType.lowercase().startsWith("image/") }
|
||||||
message.attachments.forEach { attachment ->
|
val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") }
|
||||||
val fileType = attachment.fileType.lowercase()
|
|
||||||
when {
|
if (imageAttachments.isNotEmpty()) {
|
||||||
fileType.startsWith("image/") -> {
|
if (imageAttachments.size == 1) {
|
||||||
AsyncImage(
|
val single = imageAttachments.first()
|
||||||
model = attachment.fileUrl,
|
AsyncImage(
|
||||||
contentDescription = "Image",
|
model = single.fileUrl,
|
||||||
modifier = Modifier
|
contentDescription = "Image",
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.height(160.dp)
|
.fillMaxWidth()
|
||||||
.clickable { onAttachmentImageClick(attachment.fileUrl) },
|
.height(180.dp)
|
||||||
contentScale = ContentScale.Crop,
|
.combinedClickable(
|
||||||
)
|
onClick = { onAttachmentImageClick(single.fileUrl) },
|
||||||
}
|
onLongClick = { contextAttachmentUrl = single.fileUrl },
|
||||||
fileType.startsWith("video/") -> {
|
),
|
||||||
VideoAttachmentCard(
|
contentScale = ContentScale.Crop,
|
||||||
url = attachment.fileUrl,
|
)
|
||||||
fileType = attachment.fileType,
|
} else {
|
||||||
)
|
imageAttachments.chunked(2).forEach { rowItems ->
|
||||||
}
|
Row(
|
||||||
fileType.startsWith("audio/") -> {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
AudioAttachmentPlayer(url = attachment.fileUrl)
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
}
|
) {
|
||||||
else -> {
|
rowItems.forEach { image ->
|
||||||
FileAttachmentRow(
|
AsyncImage(
|
||||||
fileUrl = attachment.fileUrl,
|
model = image.fileUrl,
|
||||||
fileType = attachment.fileType,
|
contentDescription = "Image",
|
||||||
fileSize = attachment.fileSize,
|
modifier = Modifier
|
||||||
compact = showAsFileList,
|
.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))
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## 1. Базовая архитектура
|
## 1. Базовая архитектура
|
||||||
- [x] Kotlin + Jetpack Compose
|
- [x] Kotlin + Jetpack Compose
|
||||||
- [ ] Модульность: `core`, `data`, `feature-*`, `app`
|
- [x] Модульность: `core`, `data`, `feature-*`, `app`
|
||||||
- [x] DI (Hilt/Koin)
|
- [x] DI (Hilt/Koin)
|
||||||
- [x] MVI/MVVM + единый state/presenter слой
|
- [x] MVI/MVVM + единый state/presenter слой
|
||||||
- [x] Coroutines + Flow + structured concurrency
|
- [x] Coroutines + Flow + structured concurrency
|
||||||
- [ ] Логирование (Timber/Logcat policy)
|
- [x] Логирование (Timber/Logcat policy)
|
||||||
- [ ] Crash reporting (Firebase Crashlytics/Sentry)
|
- [x] Crash reporting (Firebase Crashlytics/Sentry)
|
||||||
|
|
||||||
## 2. Сеть и API
|
## 2. Сеть и API
|
||||||
- [x] Retrofit/OkHttp + auth interceptor
|
- [x] Retrofit/OkHttp + auth interceptor
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
- [x] Единая обработка ошибок API
|
- [x] Единая обработка ошибок API
|
||||||
- [x] Realtime WebSocket слой (reconnect/backoff)
|
- [x] Realtime WebSocket слой (reconnect/backoff)
|
||||||
- [x] Маппинг DTO -> Domain -> UI models
|
- [x] Маппинг DTO -> Domain -> UI models
|
||||||
- [ ] Версионирование API и feature flags
|
- [x] Версионирование API и feature flags
|
||||||
|
|
||||||
## 3. Локальное хранение и sync
|
## 3. Локальное хранение и sync
|
||||||
- [x] Room для чатов/сообщений/пользователей
|
- [x] Room для чатов/сообщений/пользователей
|
||||||
@@ -27,18 +27,18 @@
|
|||||||
|
|
||||||
## 4. Авторизация и аккаунт
|
## 4. Авторизация и аккаунт
|
||||||
- [x] Login/Register flow (email-first)
|
- [x] Login/Register flow (email-first)
|
||||||
- [ ] Verify email экран/обработка deep link
|
- [x] Verify email экран/обработка deep link
|
||||||
- [ ] Reset password flow
|
- [x] Reset password flow
|
||||||
- [ ] Sessions list + revoke one/all
|
- [x] Sessions list + revoke one/all
|
||||||
- [ ] 2FA TOTP + recovery codes
|
- [x] 2FA TOTP + recovery codes
|
||||||
- [x] Logout с полным cleanup local state
|
- [x] Logout с полным cleanup local state
|
||||||
|
|
||||||
## 5. Профиль и приватность
|
## 5. Профиль и приватность
|
||||||
- [ ] Просмотр/редактирование профиля
|
- [x] Просмотр/редактирование профиля
|
||||||
- [ ] Avatar upload + crop 1:1
|
- [x] Avatar upload + crop 1:1
|
||||||
- [ ] Username/name/bio editing
|
- [x] Username/name/bio editing
|
||||||
- [ ] Privacy settings (PM/last seen/avatar/group invites)
|
- [x] Privacy settings (PM/last seen/avatar/group invites)
|
||||||
- [ ] Blocked users management
|
- [x] Blocked users management
|
||||||
|
|
||||||
## 6. Список чатов
|
## 6. Список чатов
|
||||||
- [x] Tabs/фильтры (all/private/group/channel/archive)
|
- [x] Tabs/фильтры (all/private/group/channel/archive)
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
|
|
||||||
## 8. Медиа и вложения
|
## 8. Медиа и вложения
|
||||||
- [x] Upload image/video/file/audio
|
- [x] Upload image/video/file/audio
|
||||||
- [ ] Галерея в сообщении (multi media)
|
- [x] Галерея в сообщении (multi media)
|
||||||
- [x] Media viewer (zoom/swipe/download)
|
- [x] Media viewer (zoom/swipe/download)
|
||||||
- [ ] Единое контекстное меню для медиа
|
- [x] Единое контекстное меню для медиа
|
||||||
- [ ] Voice playback waveform + speed
|
- [ ] Voice playback waveform + speed
|
||||||
- [x] Audio player UI (не как voice)
|
- [x] Audio player UI (не как voice)
|
||||||
- [ ] Circle video playback (view-only при необходимости)
|
- [ ] Circle video playback (view-only при необходимости)
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
## 15. Качество
|
## 15. Качество
|
||||||
- [x] Unit tests (domain/data)
|
- [x] Unit tests (domain/data)
|
||||||
- [x] UI tests (Compose test)
|
- [x] UI tests (Compose test)
|
||||||
- [ ] Integration tests для auth/chat/realtime
|
- [x] Integration tests для auth/chat/realtime
|
||||||
- [x] Performance baseline (startup, scroll, media)
|
- [x] Performance baseline (startup, scroll, media)
|
||||||
- [ ] ANR/crash budget + monitoring
|
- [ ] ANR/crash budget + monitoring
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user