feat: gallery view for My Blossom Files with per-file detail sheet

Replace the single-column list of large blob cards with an adaptive
thumbnail grid so many files no longer mean an endless scroll. Each tile
shows an image preview (or a type glyph) plus a corner badge summarizing
how many of the user's servers hold it (green check when on all, amber
cloud with a present/total count otherwise).

Tapping a tile opens a bottom sheet with the file's details: hash,
type/size, the per-server storage matrix ("Stored on"), the sync
(mirror-to-missing) button, and the copy/open/report/delete actions that
previously lived behind the card's overflow menu.

The ViewModel is unchanged — only the presentation layer moved from
list-of-cards to grid-plus-detail-sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
This commit is contained in:
Claude
2026-07-24 20:38:43 +00:00
parent 5d72a0415c
commit 3fb44dba60
2 changed files with 234 additions and 108 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,28 +31,36 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -86,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.coroutines.launch
@@ -104,6 +114,11 @@ fun BlossomBlobManagerScreen(
val error by vm.error.collectAsStateWithLifecycle()
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
// The tapped file, if any. We keep only the hash and re-resolve the row from the
// live list each recomposition so the open sheet stays in sync with mirror/delete
// updates (and closes itself when the last copy of the blob is deleted).
var selectedHash by remember { mutableStateOf<HexKey?>(null) }
pendingPayment?.let { pending ->
BlossomPaymentDialog(
host = pending.targetHost,
@@ -114,6 +129,19 @@ fun BlossomBlobManagerScreen(
)
}
selectedHash?.let { hash ->
val selected = blobs.firstOrNull { it.hash == hash }
if (selected == null) {
selectedHash = null
} else {
BlobDetailSheet(
row = selected,
vm = vm,
onDismiss = { selectedHash = null },
)
}
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
@@ -164,16 +192,20 @@ fun BlossomBlobManagerScreen(
}
else ->
LazyColumn(
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 104.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (blobs.any { it.hasMissing }) {
item { SyncAllBanner(onSyncAll = { vm.syncAll() }) }
item(span = { GridItemSpan(maxLineSpan) }) {
SyncAllBanner(onSyncAll = { vm.syncAll() })
}
}
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
GalleryTile(row, onClick = { selectedHash = row.hash })
}
}
}
@@ -229,108 +261,179 @@ private fun SyncAllBanner(onSyncAll: () -> Unit) {
}
}
@OptIn(ExperimentalLayoutApi::class)
/**
* One gallery cell: a square thumbnail (image preview or a type glyph) with a small
* corner badge summarizing how many of the user's servers hold this blob. Tapping it
* opens [BlobDetailSheet] with the storage matrix and the sync/delete/report actions.
*/
@Composable
private fun BlobCard(
private fun GalleryTile(
row: BlobRow,
onClick: () -> Unit,
) {
Box(
modifier =
Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable(onClick = onClick),
) {
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
symbol = glyphFor(row.type),
contentDescription = null,
modifier = Modifier.size(34.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
SyncBadge(
row = row,
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
)
}
}
/**
* Corner chip over a gallery tile: a green check when the blob is on every server, an
* amber cloud when some server is still missing it, plus a `present/total` count so the
* spread is legible at a glance without opening the file.
*/
@Composable
private fun SyncBadge(
row: BlobRow,
modifier: Modifier = Modifier,
) {
val synced = !row.hasMissing
val accent = if (synced) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.tertiary
Row(
modifier =
modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 7.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = if (synced) MaterialSymbols.CheckCircle else MaterialSymbols.CloudUpload,
contentDescription =
stringRes(
if (synced) R.string.blossom_on_all_servers else R.string.blossom_not_on_all_servers,
),
modifier = Modifier.size(13.dp),
tint = accent,
)
Text(
text = "${row.presentCount}/${row.servers.size}",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun BlobDetailSheet(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
onDismiss: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var reportOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header: thumbnail / file glyph + hash + overflow menu.
Row(verticalAlignment = Alignment.CenterVertically) {
BlobThumbnail(row)
Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview + identity.
Row(verticalAlignment = Alignment.CenterVertically) {
DetailThumbnail(row)
Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) {
Text(
text = row.hash.take(12) + "" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
}
// Where the file lives.
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = row.hash.take(12) + "" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
text = stringRes(R.string.blossom_stored_on),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (row.url != null) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy)) },
leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) },
onClick = {
menuOpen = false
val url = row.url
scope.launch { clipboard.setText(url) }
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_open)) },
leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) },
onClick = {
menuOpen = false
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
},
)
}
if (row.hasPresent) {
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_report)) },
leadingIcon = { MenuIcon(MaterialSymbols.Report) },
onClick = {
menuOpen = false
reportOpen = true
},
)
HorizontalDivider()
row.presentServers.forEach { server ->
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) },
leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
vm.delete(row.hash, server)
},
)
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
row.servers.forEach { ServerPill(it) }
}
}
}
// Per-server presence pills (green = has it, grey = missing, spinner = working).
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
row.servers.forEach { ServerPill(it) }
}
// Primary CTA: fill the gaps for this file.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
}
}
// Primary CTA: fill the gaps.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
// Secondary actions.
if (row.url != null) {
DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) {
val url = row.url
scope.launch { clipboard.setText(url) }
}
DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
}
}
if (row.hasPresent) {
DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true }
HorizontalDivider()
row.presentServers.forEach { server ->
DetailAction(
symbol = MaterialSymbols.Delete,
label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)),
tint = MaterialTheme.colorScheme.error,
) { vm.delete(row.hash, server) }
}
}
}
}
@@ -341,31 +444,59 @@ private fun BlobCard(
}
@Composable
private fun BlobThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(12.dp)
private fun DetailAction(
symbol: MaterialSymbol,
label: String,
tint: Color = MaterialTheme.colorScheme.onSurface,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = tint)
Text(text = label, style = MaterialTheme.typography.bodyLarge, color = tint)
}
}
@Composable
private fun DetailThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(14.dp)
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp).clip(shape),
modifier = Modifier.size(64.dp).clip(shape),
)
} else {
Box(
modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
modifier = Modifier.size(64.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage
Icon(
symbol = glyph,
symbol = glyphFor(row.type),
contentDescription = null,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
private fun glyphFor(type: String?): MaterialSymbol =
when {
type?.startsWith("image/") == true -> MaterialSymbols.Image
type?.startsWith("video/") == true -> MaterialSymbols.PlayCircle
else -> MaterialSymbols.Storage
}
@Composable
private fun ServerPill(presence: ServerPresence) {
val present = presence.state == PresenceState.PRESENT
@@ -396,14 +527,6 @@ private fun ServerPill(presence: ServerPresence) {
}
}
@Composable
private fun MenuIcon(
symbol: MaterialSymbol,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
}
private fun humanBytes(bytes: Long): String =
when {
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
+3
View File
@@ -1671,6 +1671,9 @@
<string name="blossom_sync_channel_name">Blossom sync</string>
<string name="blossom_sync_channel_description">Shows progress while copying your files across your Blossom servers.</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_stored_on">Stored on</string>
<string name="blossom_on_all_servers">On all servers</string>
<string name="blossom_not_on_all_servers">Not on all servers</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>
<string name="blossom_delete_from">Delete from…</string>
<string name="blossom_delete_from_host">Delete from %1$s</string>