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(),