diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt
index 344f4bf900..56b9a36ee4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt
@@ -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)) }
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 0dc07c6ec3..5507f72a05 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1554,11 +1554,12 @@
No stored files found on your Blossom servers.
Mirror to missing
Delete from…
+ Delete from %1$s
Report
Open
This server requires payment to upload: %1$s
Payment required
- This server charges a lightning payment to store the file. Pay from your connected wallet to continue.
+ %1$s charges a lightning payment to store this file. Pay from your connected wallet to continue.
Pay
- Pay %1$d sat