feat(blossom): redesign blob manager to match the app's modern UI

Brings the "Manage stored files" screen up to the design language main just
applied to the Media Servers screen:

- Rounded surfaceContainer cards instead of flat Cards.
- Image thumbnail, or a file-type glyph in a rounded secondaryContainer box
  for non-images.
- Monospace, truncated-both-ends hash; type · size subtitle.
- Per-server presence as pills with a status dot — allGoodColor tint for
  present, muted for missing — replacing the ambiguous same-shaped chips.
- A single MoreVert overflow (copy link / open / report / per-server delete)
  with leading icons, plus a full-width tonal "Mirror to missing" CTA.
- Icon-in-rounded-box empty/error states and an ExtendedFloatingActionButton
  refresh.
- BUD-07 pay dialog restored with a Bolt icon and tonal confirm; payment
  message now names the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-18 02:32:51 +00:00
parent e87309e038
commit 22396cf583
2 changed files with 230 additions and 144 deletions
@@ -21,27 +21,32 @@
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
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.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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -57,11 +62,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -70,11 +77,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
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.nip56Reports.ReportType
@@ -95,6 +104,7 @@ fun BlossomBlobManagerScreen(
pendingPayment?.let { pending ->
BlossomPaymentDialog(
host = vm.hostOf(pending.target),
amountSats = pending.amountSats,
reason = pending.payment.reason,
onConfirm = { vm.confirmPendingPayment() },
@@ -105,11 +115,12 @@ fun BlossomBlobManagerScreen(
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) },
floatingActionButton = {
FloatingActionButton(onClick = { vm.refresh() }) {
Icon(
symbol = MaterialSymbols.Sync,
contentDescription = stringRes(R.string.retry),
modifier = Modifier.size(22.dp),
if (blobs.isNotEmpty()) {
ExtendedFloatingActionButton(
onClick = { vm.refresh() },
icon = { Icon(symbol = MaterialSymbols.Sync, contentDescription = null, modifier = Modifier.size(20.dp)) },
text = { Text(stringRes(R.string.retry)) },
expanded = !loading,
)
}
},
@@ -119,179 +130,254 @@ fun BlossomBlobManagerScreen(
Modifier
.fillMaxSize()
.padding(
start = 12.dp,
end = 12.dp,
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
) {
when {
loading && blobs.isEmpty() ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { CircularProgressIndicator() }
loading && blobs.isEmpty() -> CenteredState { CircularProgressIndicator() }
error != null && blobs.isEmpty() ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(error ?: "", color = MaterialTheme.colorScheme.error)
TextButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) }
CenteredState {
StatusGlyph(MaterialSymbols.Warning, MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Text(error ?: "", color = MaterialTheme.colorScheme.grayText)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) }
}
blobs.isEmpty() ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { Text(stringRes(R.string.manage_stored_files_empty), color = MaterialTheme.colorScheme.grayText) }
CenteredState {
StatusGlyph(MaterialSymbols.Storage, MaterialTheme.colorScheme.grayText)
Spacer(Modifier.height(12.dp))
Text(
stringRes(R.string.manage_stored_files_empty),
color = MaterialTheme.colorScheme.grayText,
)
}
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
}
item { Spacer(Modifier.height(72.dp)) }
}
}
}
}
}
@Composable
private fun CenteredState(content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
@Composable
private fun StatusGlyph(
symbol: MaterialSymbol,
tint: Color,
) {
Box(
modifier =
Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(34.dp), tint = tint)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobCard(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
) {
var deleteMenuOpen by remember { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
var reportOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.hash.take(16) + "",
style = MaterialTheme.typography.titleSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
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)) {
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,
)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.serversPresent.forEach { server ->
AssistChip(
onClick = {},
label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) },
colors =
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.primary,
),
)
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null)
}
row.serversMissing.forEach { server ->
AssistChip(
onClick = {},
label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) },
colors =
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.grayText,
),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (row.url != null) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy)) },
leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) },
onClick = {
menuOpen = false
clipboard.setText(AnnotatedString(row.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.serversPresent.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_report)) },
leadingIcon = { MenuIcon(MaterialSymbols.Report) },
onClick = {
menuOpen = false
reportOpen = true
},
)
HorizontalDivider()
row.serversPresent.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)
},
)
}
}
}
}
}
Row(
// Per-server presence pills.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
row.serversPresent.forEach { ServerPill(vm.hostOf(it), present = true) }
row.serversMissing.forEach { ServerPill(vm.hostOf(it), present = false) }
}
// Primary CTA: fill the gaps.
if (row.serversMissing.isNotEmpty() && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (row.serversMissing.isNotEmpty() && row.url != null) {
OutlinedButton(onClick = { vm.mirrorToMissing(row) }) {
Text(stringRes(R.string.blossom_mirror_to_missing), style = MaterialTheme.typography.labelMedium)
}
}
if (row.serversPresent.isNotEmpty()) {
Column {
OutlinedButton(onClick = { deleteMenuOpen = true }) {
Text(stringRes(R.string.blossom_delete_from), style = MaterialTheme.typography.labelMedium)
}
DropdownMenu(expanded = deleteMenuOpen, onDismissRequest = { deleteMenuOpen = false }) {
row.serversPresent.forEach { server ->
DropdownMenuItem(
text = { Text(vm.hostOf(server)) },
onClick = {
deleteMenuOpen = false
vm.delete(row.hash, server)
},
)
}
}
}
}
if (row.serversPresent.isNotEmpty()) {
TextButton(onClick = { reportOpen = true }) {
Text(stringRes(R.string.blossom_report), style = MaterialTheme.typography.labelMedium)
}
}
}
if (row.url != null) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(onClick = { clipboard.setText(AnnotatedString(row.url)) }) {
Text(stringRes(R.string.copy), style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
}) {
Text(stringRes(R.string.blossom_open), style = MaterialTheme.typography.labelMedium)
}
}
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
}
}
}
if (reportOpen) {
BlossomReportDialog(
row = row,
vm = vm,
onDismiss = { reportOpen = false },
BlossomReportDialog(row = row, vm = vm, onDismiss = { reportOpen = false })
}
}
@Composable
private fun BlobThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(12.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),
)
} else {
Box(
modifier = Modifier.size(48.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,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@Composable
private fun ServerPill(
host: String,
present: Boolean,
) {
val dot = if (present) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
val bg =
if (present) {
MaterialTheme.colorScheme.allGoodColor.copy(alpha = 0.12f)
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
Row(
modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot))
Text(
text = host,
style = MaterialTheme.typography.labelMedium,
color = if (present) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText,
)
}
}
@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"
bytes >= 1_000 -> "${bytes / 1_000} KB"
else -> "$bytes B"
}
@Composable
private fun BlossomPaymentDialog(
host: String,
amountSats: Long?,
reason: String?,
onConfirm: () -> Unit,
@@ -299,18 +385,21 @@ private fun BlossomPaymentDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
title = { Text(stringRes(R.string.blossom_payment_title)) },
text = {
Text(
text =
listOfNotNull(
stringRes(R.string.blossom_payment_message),
stringRes(R.string.blossom_payment_message, host),
reason,
).joinToString("\n\n"),
)
},
confirmButton = {
Button(onClick = onConfirm) {
FilledTonalButton(onClick = onConfirm) {
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(
if (amountSats != null) {
pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt())
@@ -326,13 +415,6 @@ private fun BlossomPaymentDialog(
)
}
private fun humanBytes(bytes: Long): String =
when {
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
bytes >= 1_000 -> "${bytes / 1_000} KB"
else -> "$bytes B"
}
@Composable
private fun BlossomReportDialog(
row: BlobRow,
@@ -347,11 +429,14 @@ private fun BlossomReportDialog(
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(symbol = MaterialSymbols.Report, contentDescription = null) },
title = { Text(stringRes(R.string.blossom_report_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
OutlinedButton(onClick = { typeMenuOpen = true }) { Text(type.code) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box {
OutlinedButton(onClick = { typeMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text(type.code)
}
DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) {
ReportType.entries.forEach { rt ->
DropdownMenuItem(
@@ -373,7 +458,7 @@ private fun BlossomReportDialog(
}
},
confirmButton = {
Button(onClick = {
FilledTonalButton(onClick = {
vm.report(row.hash, server, type, comment)
onDismiss()
}) { Text(stringRes(R.string.blossom_send)) }
+2 -1
View File
@@ -1554,11 +1554,12 @@
<string name="manage_stored_files_empty">No stored files found on your Blossom 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>
<string name="blossom_report">Report</string>
<string name="blossom_open">Open</string>
<string name="blossom_payment_required">This server requires payment to upload: %1$s</string>
<string name="blossom_payment_title">Payment required</string>
<string name="blossom_payment_message">This server charges a lightning payment to store the file. Pay from your connected wallet to continue.</string>
<string name="blossom_payment_message">%1$s charges a lightning payment to store this file. Pay from your connected wallet to continue.</string>
<string name="blossom_pay">Pay</string>
<plurals name="blossom_pay_sats">
<item quantity="one">Pay %1$d sat</item>