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