diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index f4accf2d8c..a609bcf60f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -22,54 +22,80 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 import android.content.Context import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +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 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.layout.width 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.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import kotlinx.coroutines.launch +private val LiveGreen = Color(0xFF3DDC84) + @Composable fun Nip46SignerScreen( accountViewModel: AccountViewModel, @@ -86,11 +112,36 @@ fun Nip46SignerScreen( val relays by signer.listeningRelays.collectAsStateWithLifecycle() val writeable = remember { account.signer.isWriteable() } + var connectedCount by remember { mutableIntStateOf(0) } + var refreshKey by remember { mutableIntStateOf(0) } + var scanning by remember { mutableStateOf(false) } + var bunkerUri by remember { mutableStateOf(null) } - // Recompute the advertised bunker URI whenever it is being shown or its inputs change. LaunchedEffect(enabled, secret, relays) { bunkerUri = if (enabled && writeable && relays.isNotEmpty()) signer.bunkerUri() else null } + LaunchedEffect(enabled, refreshKey) { + connectedCount = + account.signerPermissionLedger.store + .allPolicies() + .keys + .count { it.startsWith("${Nip46PermissionAuthorizer.COORDINATE_PREFIX}:") } + } + + fun onConnect(uri: String) { + scope.launch { + val result = signer.connectViaNostrConnect(uri.trim()) + Toast.makeText(context, describe(context, result), Toast.LENGTH_LONG).show() + refreshKey++ + } + } + + if (scanning) { + SimpleQrCodeScanner { contents -> + scanning = false + contents?.let { onConnect(it) } + } + } Scaffold( topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_title), nav) }, @@ -101,157 +152,219 @@ fun Nip46SignerScreen( .fillMaxSize() .padding(padding) .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), ) { - HeaderCard() - if (!writeable) { - Text( - stringResource(R.string.nip46_signer_readonly), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) + ReadOnlyNotice() return@Column } - EnableRow(enabled = enabled, onToggle = { signer.setEnabled(it) }) + if (!enabled) { + DisabledHero(onEnable = { signer.setEnabled(true) }) + return@Column + } - if (enabled) { - if (relays.isEmpty()) { - Text( - stringResource(R.string.nip46_signer_status_no_relays), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } else { - Text( - pluralStringResource(R.plurals.nip46_signer_status_listening, relays.size, relays.size), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + LiveStatusCard( + relayCount = relays.size, + connectedCount = connectedCount, + onToggleOff = { signer.setEnabled(false) }, + ) - bunkerUri?.let { uri -> - BunkerAddressCard( - uri = uri, - onCopy = { - clipboard.setText(AnnotatedString(uri)) - Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() - }, - onRegenerate = { - signer.regenerateSecret() - Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() - }, - ) - } + if (relays.isEmpty()) { + WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) + } - HorizontalDivider() - - ConnectAppSection( - onConnect = { uri -> - scope.launch { - val result = signer.connectViaNostrConnect(uri.trim()) - Toast.makeText(context, describe(context, result), Toast.LENGTH_SHORT).show() - } + bunkerUri?.let { uri -> + QrHeroCard( + uri = uri, + onCopy = { + clipboard.setText(AnnotatedString(uri)) + Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() + }, + onRegenerate = { + signer.regenerateSecret() + Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() }, ) - - OutlinedButton( - onClick = { nav.nav(Route.ConnectedApps) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.nip46_signer_manage_apps)) - } } + + ConnectSection( + onScan = { scanning = true }, + onPaste = { onConnect(it) }, + ) + + ConnectedAppsRow( + count = connectedCount, + onClick = { nav.nav(Route.ConnectedApps) }, + ) } } } +// ---------------------------------------------------------------------------- + @Composable -private fun HeaderCard() { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), +private fun DisabledHero(onEnable: () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = + Modifier + .size(104.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, ) { Icon( MaterialSymbols.Key, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp), - ) - Text( - stringResource(R.string.nip46_signer_explainer), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(52.dp), ) } + Text( + stringResource(R.string.nip46_signer_hero_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.nip46_signer_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Button( + onClick = onEnable, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon(MaterialSymbols.Key, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.nip46_signer_turn_on), fontWeight = FontWeight.SemiBold) + } } } @Composable -private fun EnableRow( - enabled: Boolean, - onToggle: (Boolean) -> Unit, +private fun LiveStatusCard( + relayCount: Int, + connectedCount: Int, + onToggleOff: () -> Unit, ) { - Row( + Card( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { - Text( - stringResource(R.string.nip46_signer_enable), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - Switch(checked = enabled, onCheckedChange = onToggle) + Row( + modifier = Modifier.padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + LiveDot() + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.nip46_signer_live), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + buildString { + append(pluralStringResource(R.plurals.nip46_signer_connected_count, connectedCount, connectedCount)) + append(" ยท ") + append(pluralStringResource(R.plurals.nip46_signer_status_listening, relayCount, relayCount)) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + Switch(checked = true, onCheckedChange = { onToggleOff() }) + } } } @Composable -private fun BunkerAddressCard( +private fun LiveDot() { + val transition = rememberInfiniteTransition(label = "live") + val alpha by transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "pulse", + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(18.dp)) { + Canvas(Modifier.size(18.dp)) { drawCircle(color = LiveGreen, alpha = alpha * 0.35f) } + Canvas(Modifier.size(9.dp)) { drawCircle(color = LiveGreen) } + } +} + +@Composable +private fun QrHeroCard( uri: String, onCopy: () -> Unit, onRegenerate: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), ) { + Surface( + shape = RoundedCornerShape(20.dp), + color = Color.White, + ) { + QrCodeDrawer( + contents = uri, + modifier = + Modifier + .padding(16.dp) + .size(232.dp), + ) + } Text( - stringResource(R.string.nip46_signer_bunker_uri_label), - style = MaterialTheme.typography.titleSmall, + stringResource(R.string.nip46_signer_scan_caption), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, ) Text( uri, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, + maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - Text( - stringResource(R.string.nip46_signer_bunker_uri_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Button(onClick = onCopy, modifier = Modifier.weight(1f)) { + FilledTonalButton(onClick = onCopy, modifier = Modifier.weight(1f)) { + Icon(MaterialSymbols.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.nip46_signer_copy)) } FilledTonalButton(onClick = onRegenerate, modifier = Modifier.weight(1f)) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.nip46_signer_regenerate)) } } @@ -260,31 +373,141 @@ private fun BunkerAddressCard( } @Composable -private fun ConnectAppSection(onConnect: (String) -> Unit) { +private fun ConnectSection( + onScan: () -> Unit, + onPaste: (String) -> Unit, +) { + var showPaste by remember { mutableStateOf(false) } var input by remember { mutableStateOf("") } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( stringResource(R.string.nip46_signer_connect_label), style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) }, + fontWeight = FontWeight.SemiBold, ) Button( - onClick = { - if (input.isNotBlank()) { - onConnect(input) - input = "" - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = input.isNotBlank(), + onClick = onScan, + modifier = + Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(16.dp), ) { - Text(stringResource(R.string.nip46_signer_connect_button)) + Icon(MaterialSymbols.CameraAlt, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.nip46_signer_scan_connect), fontWeight = FontWeight.SemiBold) + } + + TextButton(onClick = { showPaste = !showPaste }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nip46_signer_paste_link)) + } + + AnimatedVisibility(visible = showPaste) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) }, + ) + Button( + onClick = { + if (input.isNotBlank()) { + onPaste(input) + input = "" + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = input.isNotBlank(), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.filledTonalButtonColors(), + ) { + Text(stringResource(R.string.nip46_signer_connect_button)) + } + } + } + } +} + +@Composable +private fun ConnectedAppsRow( + count: Int, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.nip46_signer_manage_apps), + style = MaterialTheme.typography.titleSmall, + ) + Text( + pluralStringResource(R.plurals.nip46_signer_connected_count, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun WarningCard(message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Text( + message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +@Composable +private fun ReadOnlyNotice() { + Card( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(28.dp)) + Text( + stringResource(R.string.nip46_signer_readonly), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 105f369644..5b43f12ee2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -926,6 +926,16 @@ Could not connect: %1$s This is a read-only account and cannot sign. Remote signer app + Sign for other apps + Turn on signer + Live + Scan to connect an app to your key + Scan a code + Paste a link instead + + %1$d connected app + %1$d connected apps + Signing trust level Capabilities Forget this app