From 07e30ccac47b2b6faaaccf31291e5c9a9fb2961e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 12:20:34 +0000 Subject: [PATCH] desktop: localize the entire UI through the Strings system Route every remaining hardcoded English string across the desktop screens (login, unlock, incoming requests, applications + detail, relays, settings and its dialogs, sidebar/account switcher, tray menu and notifications, RememberType labels) through core/Strings, and make each composable observe Strings.currentLanguage so the whole interface recomposes on a live language switch. Reused Android string keys pick up the existing 14-language translations; desktop-only strings are added to strings_en.xml under a d_ prefix and fall back to English in other locales. RememberType now carries a labelKey resolved via label(language) instead of a hardcoded label. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i --- desktop/README.md | 5 + .../greenart7c3/nostrsigner/desktop/Main.kt | 12 +- .../nostrsigner/desktop/core/Models.kt | 23 +-- .../desktop/core/SignerDescriptions.kt | 2 +- .../greenart7c3/nostrsigner/desktop/ui/App.kt | 31 ++-- .../desktop/ui/ApplicationDetailScreen.kt | 41 +++-- .../desktop/ui/ApplicationsScreen.kt | 42 +++-- .../nostrsigner/desktop/ui/Components.kt | 9 +- .../desktop/ui/IncomingRequestsScreen.kt | 15 +- .../nostrsigner/desktop/ui/LoginScreen.kt | 31 ++-- .../nostrsigner/desktop/ui/RelaysScreen.kt | 16 +- .../nostrsigner/desktop/ui/SettingsScreen.kt | 168 +++++++++--------- .../nostrsigner/desktop/ui/UiState.kt | 5 +- .../nostrsigner/desktop/ui/UnlockScreen.kt | 11 +- .../src/main/resources/i18n/strings_en.xml | 149 ++++++++++++++++ .../nostrsigner/desktop/StringsTest.kt | 19 ++ 16 files changed, 395 insertions(+), 184 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index fa50b81e..2ea253e4 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -28,6 +28,11 @@ for the JVM) and mirrors the mobile UI and permission model. - Native desktop layout: sidebar navigation with an account switcher, dense list views, and keyboard shortcuts - Light/dark theme using the Amber palette +- Fully localized UI in 14 languages, switchable live under Settings → + Language. The Android `strings.xml` translations (and event-kind + descriptions) are bundled verbatim under `resources/i18n/strings_.xml` + and loaded by `core/Strings.kt`; desktop-only strings live in + `strings_en.xml` (prefixed `d_`) and fall back to English in other locales ## Keyboard shortcuts diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index ca8ba0ce..129cd788 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -22,6 +22,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.SettingsStore +import com.greenart7c3.nostrsigner.desktop.core.Strings import com.greenart7c3.nostrsigner.desktop.core.describe import com.greenart7c3.nostrsigner.desktop.ui.App import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme @@ -105,6 +106,7 @@ fun main() { val windowState = rememberWindowState(size = DpSize(1100.dp, 780.dp)) val pending by AmberDesktop.engine.pending.collectAsState() val settings by SettingsStore.settings.collectAsState() + val language by Strings.currentLanguage.collectAsState() var windowVisible by remember { mutableStateOf(true) } val trayState = rememberTrayState() // AWT may claim support but fail to actually add the icon (e.g. bare @@ -121,7 +123,7 @@ fun main() { trayState.sendNotification( Notification( title = "Amber", - message = "${request.appName} ${request.type.describe(request.kind)}", + message = "${request.appName} ${request.type.describe(request.kind, language)}", type = Notification.Type.Info, ), ) @@ -136,18 +138,18 @@ fun main() { Tray( icon = painterResource("icon.png"), state = trayState, - tooltip = if (pending.isEmpty()) "Amber — Nostr signer" else "Amber — ${pending.size} pending request(s)", + tooltip = if (pending.isEmpty()) Strings.get("d_tray_tooltip", language) else Strings.format("d_tray_pending", pending.size, language = language), onAction = { windowVisible = true }, menu = { Item( - if (windowVisible) "Hide Amber" else "Open Amber", + if (windowVisible) Strings.get("d_tray_hide", language) else Strings.get("d_tray_open", language), onClick = { windowVisible = !windowVisible }, ) if (lockStatus == PassphraseLock.Status.UNLOCKED) { - Item("Lock now", onClick = { PassphraseLock.lock() }) + Item(Strings.get("d_lock_now", language), onClick = { PassphraseLock.lock() }) } Separator() - Item("Quit Amber", onClick = ::exitApplication) + Item(Strings.get("d_tray_quit", language), onClick = ::exitApplication) }, ) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt index 5a1e5573..78428ccf 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt @@ -28,17 +28,20 @@ enum class EncryptionType { } /** Mirrors the Android `RememberType` screen codes so exports stay compatible. */ -enum class RememberType(val screenCode: Int, val label: String) { - NEVER(0, "Just once"), - ONE_MINUTE(1, "1 minute"), - FIVE_MINUTES(2, "5 minutes"), - TEN_MINUTES(3, "10 minutes"), - ALWAYS(4, "Always"), - ONE_HOUR(5, "1 hour"), - ONE_DAY(6, "1 day"), - ONE_WEEK(7, "1 week"), +enum class RememberType(val screenCode: Int, val labelKey: String) { + NEVER(0, "d_remember_never"), + ONE_MINUTE(1, "d_remember_1m"), + FIVE_MINUTES(2, "d_remember_5m"), + TEN_MINUTES(3, "d_remember_10m"), + ALWAYS(4, "d_remember_always"), + ONE_HOUR(5, "d_remember_1h"), + ONE_DAY(6, "d_remember_1d"), + ONE_WEEK(7, "d_remember_1w"), ; + /** Localized display label. */ + fun label(language: String = Strings.currentLanguage.value): String = Strings.get(labelKey, language) + fun acceptUntil(): Long = when (this) { ALWAYS -> Long.MAX_VALUE / 1000 ONE_MINUTE -> TimeUtils.now() + 60 @@ -219,6 +222,6 @@ fun SignerType.describe(kind: Int?, language: String = Strings.currentLanguage.v SignerType.SWITCH_RELAYS -> Strings.get("switch_relays", language) SignerType.LOGOUT -> Strings.get("logout", language) SignerType.INVALID -> Strings.get("invalid_request", language) - SignerType.PING -> "Ping" + SignerType.PING -> Strings.get("ping", language) else -> "${Strings.get("requests", language)} ${SignerDescriptions.permission(methodString(), kind, language)}" } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/SignerDescriptions.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/SignerDescriptions.kt index 6fa5f94e..6e6ee15a 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/SignerDescriptions.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/SignerDescriptions.kt @@ -25,7 +25,7 @@ object SignerDescriptions { "decrypt_event" -> Strings.get("decrypt_event", language) "encrypt_tag_array" -> Strings.get("encrypt_tag_array", language) "decrypt_tag_array" -> Strings.get("decrypt_tag_array", language) - "ping" -> "Ping" + "ping" -> Strings.get("ping", language) "sign_event" -> signEventDescription(kind, language) "connect" -> Strings.get("would_like_your_permission_to_read_your_public_key_and_sign_events_on_your_behalf", language) "nip" -> nipDescription(kind, language).ifEmpty { Strings.format("event_kind", kind.toString(), language = language) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt index 0a25bde1..ea9814c6 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt @@ -55,14 +55,15 @@ import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock +import com.greenart7c3.nostrsigner.desktop.core.Strings import com.greenart7c3.nostrsigner.desktop.core.toShortenHex import kotlinx.coroutines.launch -sealed class Route(val title: String, val subtitle: String, val icon: ImageVector) { - data object IncomingRequest : Route("Incoming requests", "Approve or reject what applications ask this signer to do", Icons.Default.Notifications) - data object Applications : Route("Applications", "Connected applications and their permissions", Icons.Default.Apps) - data object Relays : Route("Relays", "Default relays used by bunker connections", Icons.Default.CellTower) - data object Settings : Route("Settings", "Account, security and application preferences", Icons.Default.Settings) +sealed class Route(val titleKey: String, val subtitleKey: String, val icon: ImageVector) { + data object IncomingRequest : Route("d_route_incoming_title", "d_route_incoming_sub", Icons.Default.Notifications) + data object Applications : Route("applications", "d_route_applications_sub", Icons.Default.Apps) + data object Relays : Route("relays", "d_route_relays_sub", Icons.Default.CellTower) + data object Settings : Route("settings", "d_route_settings_sub", Icons.Default.Settings) } val sidebarRoutes = listOf(Route.IncomingRequest, Route.Applications, Route.Relays, Route.Settings) @@ -77,6 +78,7 @@ fun App() { val currentRoute by UiState.currentRoute.collectAsState() val selectedApplication by UiState.selectedApplication.collectAsState() + val language by Strings.currentLanguage.collectAsState() LaunchedEffect(Unit) { Toaster.messages.collect { snackbarHostState.showSnackbar(it) } @@ -132,9 +134,9 @@ fun App() { val selectedApp = selectedApplication Column(Modifier.widthIn(max = 880.dp)) { if (selectedApp == null) { - Text(currentRoute.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text(Strings.get(currentRoute.titleKey, language), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text( - currentRoute.subtitle, + Strings.get(currentRoute.subtitleKey, language), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -186,7 +188,7 @@ private fun Sidebar( modifier = Modifier.padding(horizontal = 8.dp), ) Text( - "Nostr event signer", + Strings.get("d_tagline"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 8.dp), @@ -218,6 +220,8 @@ private fun SidebarItem( shortcut: String, onClick: () -> Unit, ) { + val language by Strings.currentLanguage.collectAsState() + val title = Strings.get(route.titleKey, language) Surface( shape = RoundedCornerShape(8.dp), color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface, @@ -229,10 +233,10 @@ private fun SidebarItem( .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(route.icon, route.title, Modifier.size(18.dp)) + Icon(route.icon, title, Modifier.size(18.dp)) Spacer(Modifier.width(10.dp)) Text( - route.title, + title, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -269,6 +273,7 @@ private fun SidebarItem( private fun AccountSwitcher(account: DesktopAccount) { val accounts by AccountsStore.accounts.collectAsState() val name by account.name.collectAsState() + val language by Strings.currentLanguage.collectAsState() val scope = rememberCoroutineScope() var expanded by remember { mutableStateOf(false) } @@ -296,7 +301,7 @@ private fun AccountSwitcher(account: DesktopAccount) { Spacer(Modifier.width(10.dp)) Column(Modifier.weight(1f)) { Text( - name.ifBlank { "Account" }, + name.ifBlank { Strings.get("account", language) }, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -309,7 +314,7 @@ private fun AccountSwitcher(account: DesktopAccount) { overflow = TextOverflow.Ellipsis, ) } - Icon(Icons.Default.UnfoldMore, "Switch account", Modifier.size(18.dp)) + Icon(Icons.Default.UnfoldMore, Strings.get("d_switch_account", language), Modifier.size(18.dp)) } DropdownMenu( @@ -334,7 +339,7 @@ private fun AccountSwitcher(account: DesktopAccount) { } HorizontalDivider() DropdownMenuItem( - text = { Text("Add an account") }, + text = { Text(Strings.get("d_add_an_account", language)) }, leadingIcon = { Icon(Icons.Default.Add, null, Modifier.size(18.dp)) }, onClick = { expanded = false diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt index e0086bba..c1e71e00 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.SignerDescriptions +import com.greenart7c3.nostrsigner.desktop.core.Strings import com.greenart7c3.nostrsigner.desktop.core.toShortenHex import com.vitorpamplona.quartz.utils.TimeUtils import java.text.DateFormat @@ -51,6 +53,7 @@ fun ApplicationDetailScreen( val store = AmberDesktop.store(account.npub) val apps by store.apps.collectAsState() val history by store.history.collectAsState() + val language by Strings.currentLanguage.collectAsState() val app = apps.firstOrNull { it.app.key == appKey } var tab by remember { mutableStateOf(0) } @@ -64,7 +67,7 @@ fun ApplicationDetailScreen( Column(Modifier.fillMaxSize()) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, Strings.get("d_back", language)) } Column { Text( @@ -74,8 +77,8 @@ fun ApplicationDetailScreen( ) Text( buildString { - append("Key: ${app.app.key.toShortenHex()}") - if (app.app.relays.isNotEmpty()) append(" · Relays: ${app.app.relays.joinToString()}") + append(Strings.format("d_key_label", app.app.key.toShortenHex(), language = language)) + if (app.app.relays.isNotEmpty()) append(" · ${Strings.format("d_relays_label", app.app.relays.joinToString(), language = language)}") }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -88,23 +91,23 @@ fun ApplicationDetailScreen( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Name") }, + label = { Text(Strings.get("name", language)) }, singleLine = true, modifier = Modifier.widthIn(max = 420.dp).weight(1f, fill = false), ) AmberOutlinedButton( - text = "Save", + text = Strings.get("save", language), onClick = { store.upsert(app.copy(app = app.app.copy(name = name))) - Toaster.toast("Application updated") + Toaster.toast(Strings.get("d_application_updated", language)) }, ) } Spacer(Modifier.height(12.dp)) TabRow(selectedTabIndex = tab) { - Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Permissions") }) - Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Activity") }) + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(Strings.get("permissions", language)) }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(Strings.get("d_activity", language)) }) } Spacer(Modifier.height(8.dp)) @@ -121,17 +124,17 @@ fun ApplicationDetailScreen( ) { Column(Modifier.weight(1f)) { Text( - com.greenart7c3.nostrsigner.desktop.core.SignerDescriptions.permission(permission.type, permission.kind), + SignerDescriptions.permission(permission.type, permission.kind, language), style = MaterialTheme.typography.bodyMedium, ) val until = if (permission.acceptable) permission.acceptUntil else permission.rejectUntil val untilLabel = when { - until >= Long.MAX_VALUE / 1000 -> "always" - until > TimeUtils.now() -> "until ${DateFormat.getDateTimeInstance().format(Date(until * 1000))}" - else -> "expired" + until >= Long.MAX_VALUE / 1000 -> Strings.get("d_until_always", language) + until > TimeUtils.now() -> Strings.format("d_until", DateFormat.getDateTimeInstance().format(Date(until * 1000)), language = language) + else -> Strings.get("d_expired", language) } Text( - (if (permission.acceptable) "Accept" else "Reject") + " · $untilLabel", + (if (permission.acceptable) Strings.get("d_accept", language) else Strings.get("reject", language)) + " · $untilLabel", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -155,7 +158,7 @@ fun ApplicationDetailScreen( store.upsert(app.copy(permissions = newPermissions)) }, ) { - Icon(Icons.Default.Delete, "Delete permission") + Icon(Icons.Default.Delete, Strings.get("d_delete_permission", language)) } } HorizontalDivider() @@ -170,19 +173,19 @@ fun ApplicationDetailScreen( ) { if (appHistory.isEmpty()) { item { - Text("No activity yet", style = MaterialTheme.typography.bodyMedium) + Text(Strings.get("d_no_activity", language), style = MaterialTheme.typography.bodyMedium) } } items(appHistory.size) { index -> val entry = appHistory[index] Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text( - entry.type + (entry.kind?.let { " (kind $it)" } ?: ""), + entry.type + (entry.kind?.let { " (${Strings.format("d_kind_paren", it, language = language)})" } ?: ""), style = MaterialTheme.typography.bodyMedium, ) Text( "${DateFormat.getDateTimeInstance().format(Date(entry.time * 1000))} · " + - if (entry.accepted) "accepted" else "rejected", + if (entry.accepted) Strings.get("d_accepted", language) else Strings.get("d_rejected", language), style = MaterialTheme.typography.bodySmall, ) HorizontalDivider() @@ -193,7 +196,7 @@ fun ApplicationDetailScreen( Spacer(Modifier.height(8.dp)) AmberOutlinedButton( - text = "Disconnect and delete application", + text = Strings.get("d_disconnect_delete", language), onClick = { // Deleting drops this screen from composition, so run the // unsubscribe on the application scope (not this composable's) @@ -202,7 +205,7 @@ fun ApplicationDetailScreen( AmberDesktop.applicationIOScope.launch { store.delete(appKey) AmberDesktop.engine.updateFilter() - Toaster.toast("Application removed") + Toaster.toast(Strings.get("d_application_removed", language)) } onBack() }, diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt index dd82d2f1..07e5768c 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.Strings import java.text.DateFormat import java.util.Date import kotlinx.coroutines.launch @@ -49,20 +50,21 @@ fun ApplicationsScreen( ) { val store = AmberDesktop.store(account.npub) val apps by store.apps.collectAsState() + val language by Strings.currentLanguage.collectAsState() var showNostrConnectDialog by remember { mutableStateOf(false) } var showBunkerDialog by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - AmberButton(text = "Connect with nostrconnect://", onClick = { showNostrConnectDialog = true }) - AmberOutlinedButton(text = "Create a bunker connection", onClick = { showBunkerDialog = true }) + AmberButton(text = Strings.get("d_connect_nostrconnect", language), onClick = { showNostrConnectDialog = true }) + AmberOutlinedButton(text = Strings.get("d_create_bunker", language), onClick = { showBunkerDialog = true }) } Spacer(Modifier.height(16.dp)) if (apps.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - "No applications connected yet", + Strings.get("d_no_apps", language), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -87,10 +89,10 @@ fun ApplicationsScreen( ) Text( buildString { - append(if (app.app.isConnected) "Connected" else "Waiting for the app to connect") - append(" · ${app.permissions.size} permissions") + append(if (app.app.isConnected) Strings.get("d_app_connected", language) else Strings.get("d_app_waiting", language)) + append(" · ${Strings.format("d_permissions_count", app.permissions.size, language = language)}") if (app.app.lastUsed > 0) { - append(" · last used ${DateFormat.getDateTimeInstance().format(Date(app.app.lastUsed * 1000))}") + append(" · ${Strings.format("d_last_used", DateFormat.getDateTimeInstance().format(Date(app.app.lastUsed * 1000)), language = language)}") } }, style = MaterialTheme.typography.bodySmall, @@ -124,13 +126,14 @@ private fun NostrConnectDialog( ) { var uri by remember { mutableStateOf("") } val scope = rememberCoroutineScope() + val language by Strings.currentLanguage.collectAsState() AlertDialog( onDismissRequest = onDismiss, - title = { Text("Add an application") }, + title = { Text(Strings.get("d_add_an_application", language)) }, text = { Column { - Text("Paste the nostrconnect:// URI shown by the application.") + Text(Strings.get("d_paste_nostrconnect", language)) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = uri, @@ -145,7 +148,7 @@ private fun NostrConnectDialog( onClick = { scope.launch { if (!uri.trim().startsWith("nostrconnect://")) { - Toaster.toast("Invalid nostrconnect URI") + Toaster.toast(Strings.get("d_invalid_nostrconnect", language)) return@launch } val error = AmberDesktop.engine.addNostrConnect(uri, account) @@ -156,10 +159,10 @@ private fun NostrConnectDialog( } } }, - ) { Text("Add") } + ) { Text(Strings.get("add", language)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(Strings.get("cancel", language)) } }, ) } @@ -173,10 +176,11 @@ private fun NewBunkerDialog( var bunkerUri by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val clipboard = LocalClipboardManager.current + val language by Strings.currentLanguage.collectAsState() AlertDialog( onDismissRequest = onDismiss, - title = { Text(if (bunkerUri == null) "Add a nsecBunker" else "Bunker connection created") }, + title = { Text(if (bunkerUri == null) Strings.get("d_add_nsecbunker", language) else Strings.get("d_bunker_created", language)) }, text = { Column( Modifier.verticalScroll(rememberScrollState()), @@ -184,12 +188,12 @@ private fun NewBunkerDialog( ) { val uri = bunkerUri if (uri == null) { - Text("Creates a bunker:// URI you can paste (or scan) in the client application.") + Text(Strings.get("d_bunker_desc", language)) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Application name") }, + label = { Text(Strings.get("d_application_name", language)) }, modifier = Modifier.fillMaxWidth(), ) } else { @@ -205,7 +209,7 @@ private fun NewBunkerDialog( TextButton( onClick = { if (name.isBlank()) { - Toaster.toast("Name is required") + Toaster.toast(Strings.get("d_name_required", language)) return@TextButton } scope.launch { @@ -216,18 +220,18 @@ private fun NewBunkerDialog( ) } }, - ) { Text("Create") } + ) { Text(Strings.get("d_create", language)) } } else { TextButton( onClick = { clipboard.setText(AnnotatedString(uri)) - Toaster.toast("Copied to the clipboard") + Toaster.toast(Strings.get("d_copied_clipboard", language)) }, - ) { Text("Copy") } + ) { Text(Strings.get("copy", language).trim()) } } }, dismissButton = { - TextButton(onClick = onDismiss) { Text(if (bunkerUri == null) "Cancel" else "Close") } + TextButton(onClick = onDismiss) { Text(if (bunkerUri == null) Strings.get("cancel", language) else Strings.get("d_close", language)) } }, ) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt index 4d95dc3a..df794c2b 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,6 +25,7 @@ import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.Strings import com.greenart7c3.nostrsigner.desktop.core.rememberTypeDisplayOrder import java.awt.image.BufferedImage import kotlinx.coroutines.flow.MutableSharedFlow @@ -84,11 +86,12 @@ fun RememberTypeSelector( onValueChange: (RememberType) -> Unit, ) { var expanded by remember { mutableStateOf(false) } + val language by Strings.currentLanguage.collectAsState() OutlinedButton( onClick = { expanded = true }, shape = ButtonBorder, ) { - Text("Remember: ${value.label}", style = MaterialTheme.typography.bodySmall) + Text(Strings.format("d_remember_prefix", value.label(language), language = language), style = MaterialTheme.typography.bodySmall) } DropdownMenu( expanded = expanded, @@ -96,7 +99,7 @@ fun RememberTypeSelector( ) { rememberTypeDisplayOrder.forEach { type -> DropdownMenuItem( - text = { Text(type.label) }, + text = { Text(type.label(language)) }, onClick = { onValueChange(type) expanded = false @@ -130,7 +133,7 @@ fun QrCodeImage( } Image( bitmap = image, - contentDescription = "QR code", + contentDescription = Strings.get("d_qr_code"), modifier = modifier.size(size), ) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt index 3b79a690..7fa0d2a8 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt @@ -45,14 +45,15 @@ import com.greenart7c3.nostrsigner.desktop.core.toShortenHex @Composable fun IncomingRequestsScreen(account: DesktopAccount) { val pending by AmberDesktop.engine.pending.collectAsState() + val language by Strings.currentLanguage.collectAsState() if (pending.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("No requests waiting for approval", style = MaterialTheme.typography.titleMedium) + Text(Strings.get("d_no_requests_title", language), style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(8.dp)) Text( - "Connect an application from the Applications tab and its signing requests will show up here.", + Strings.get("d_no_requests_sub", language), style = MaterialTheme.typography.bodyMedium, ) } @@ -168,7 +169,7 @@ private fun RequestCard( verticalAlignment = Alignment.CenterVertically, ) { AmberButton( - text = if (working) "Working…" else "Approve", + text = if (working) Strings.get("d_working", language) else Strings.get("d_approve", language), enabled = !working, onClick = { working = true @@ -180,20 +181,20 @@ private fun RequestCard( if (req.type == SignerType.CONNECT) RememberType.ALWAYS else rememberType, grantedPermissions, ) - Toaster.toast("Request approved") + Toaster.toast(Strings.get("d_request_approved", language)) }, ) AmberOutlinedButton( - text = "Reject", + text = Strings.get("reject", language), onClick = { working = true AmberDesktop.engine.reject(req, rememberType) - Toaster.toast("Request rejected") + Toaster.toast(Strings.get("d_request_rejected", language)) }, ) Spacer(Modifier.weight(1f)) Text( - "↑↓ select · ←→ remember · ${shortcutLabel("↵")} approve · ${shortcutLabel("↵", shift = true)} reject", + Strings.format("d_shortcut_hint", shortcutLabel("↵"), shortcutLabel("↵", shift = true), language = language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt index 11502720..e839c334 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +40,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.Session import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import com.greenart7c3.nostrsigner.desktop.core.Strings import kotlinx.coroutines.launch @Composable @@ -47,6 +49,7 @@ fun LoginScreen( snackbarHostState: SnackbarHostState, ) { val scope = rememberCoroutineScope() + val language by Strings.currentLanguage.collectAsState() var tab by remember { mutableStateOf(0) } LaunchedEffect(Unit) { @@ -70,15 +73,15 @@ fun LoginScreen( fontWeight = FontWeight.Bold, ) Text( - "Nostr event signer", + Strings.get("d_tagline", language), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, ) Spacer(Modifier.height(24.dp)) TabRow(selectedTabIndex = tab) { - Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Add a key") }) - Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Create a new key") }) + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(Strings.get("d_add_a_key", language)) }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(Strings.get("d_create_a_new_key", language)) }) } Spacer(Modifier.height(24.dp)) @@ -90,7 +93,7 @@ fun LoginScreen( if (hasAccounts) { TextButton(onClick = { Session.addingAccount.value = false }) { - Text("Cancel") + Text(Strings.get("cancel", language)) } } } @@ -100,6 +103,7 @@ fun LoginScreen( @Composable private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { + val language by Strings.currentLanguage.collectAsState() var key by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var working by remember { mutableStateOf(false) } @@ -107,7 +111,7 @@ private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { OutlinedTextField( value = key, onValueChange = { key = it }, - label = { Text("nsec, ncryptsec, hex key or seed words") }, + label = { Text(Strings.get("d_import_key_label", language)) }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -117,7 +121,7 @@ private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { OutlinedTextField( value = password, onValueChange = { password = it }, - label = { Text("Password") }, + label = { Text(Strings.get("d_password", language)) }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -125,7 +129,7 @@ private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { } Spacer(Modifier.height(16.dp)) AmberButton( - text = if (working) "Adding…" else "Add key", + text = if (working) Strings.get("d_adding", language) else Strings.get("d_add_key", language), fillWidth = true, enabled = key.isNotBlank() && !working, onClick = { @@ -141,7 +145,7 @@ private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { Session.onAccountAdded(account) }, onFailure = { - Toaster.toast(it.message ?: "Invalid key") + Toaster.toast(it.message ?: Strings.get("d_invalid_key", language)) }, ) working = false @@ -152,6 +156,7 @@ private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { @Composable private fun NewKeyPane(scope: kotlinx.coroutines.CoroutineScope) { + val language by Strings.currentLanguage.collectAsState() var name by remember { mutableStateOf("") } var working by remember { mutableStateOf(false) } val seedWords = remember { AccountManager.generateSeedWords() } @@ -159,13 +164,13 @@ private fun NewKeyPane(scope: kotlinx.coroutines.CoroutineScope) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Name (optional)") }, + label = { Text(Strings.get("d_name_optional", language)) }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(16.dp)) - Text("Your seed words", style = MaterialTheme.typography.titleMedium) + Text(Strings.get("d_your_seed_words", language), style = MaterialTheme.typography.titleMedium) Text( - "Write them down and keep them safe. They are the only backup of your new key.", + Strings.get("d_seed_words_desc", language), style = MaterialTheme.typography.bodySmall, ) Spacer(Modifier.height(8.dp)) @@ -186,7 +191,7 @@ private fun NewKeyPane(scope: kotlinx.coroutines.CoroutineScope) { } Spacer(Modifier.height(16.dp)) AmberButton( - text = if (working) "Creating…" else "Create account", + text = if (working) Strings.get("d_creating", language) else Strings.get("d_create_account", language), fillWidth = true, enabled = !working, onClick = { @@ -203,7 +208,7 @@ private fun NewKeyPane(scope: kotlinx.coroutines.CoroutineScope) { Session.onAccountAdded(account) }, onFailure = { - Toaster.toast(it.message ?: "Failed to create the key") + Toaster.toast(it.message ?: Strings.get("d_failed_create_key", language)) }, ) working = false diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt index 31a37053..25ad92ec 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt @@ -31,12 +31,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.SettingsStore +import com.greenart7c3.nostrsigner.desktop.core.Strings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.launch @Composable fun RelaysScreen() { val settings by SettingsStore.settings.collectAsState() + val language by Strings.currentLanguage.collectAsState() var newRelay by remember { mutableStateOf("") } val scope = rememberCoroutineScope() @@ -45,16 +47,16 @@ fun RelaysScreen() { OutlinedTextField( value = newRelay, onValueChange = { newRelay = it }, - label = { Text("wss://relay.example.com") }, + label = { Text(Strings.get("d_relay_hint", language)) }, singleLine = true, modifier = Modifier.widthIn(max = 420.dp).weight(1f, fill = false), ) AmberOutlinedButton( - text = "Add", + text = Strings.get("add", language), onClick = { val normalized = RelayUrlNormalizer.normalizeOrNull(newRelay.trim()) if (normalized == null) { - Toaster.toast("Invalid relay URL") + Toaster.toast(Strings.get("d_invalid_relay", language)) return@AmberOutlinedButton } SettingsStore.update { @@ -84,7 +86,7 @@ fun RelaysScreen() { IconButton( onClick = { if (settings.defaultRelays.size == 1) { - Toaster.toast("At least one relay is required") + Toaster.toast(Strings.get("d_one_relay_required", language)) return@IconButton } SettingsStore.update { @@ -93,7 +95,7 @@ fun RelaysScreen() { scope.launch { AmberDesktop.engine.updateFilter() } }, ) { - Icon(Icons.Default.Delete, "Remove relay") + Icon(Icons.Default.Delete, Strings.get("d_remove_relay", language)) } } HorizontalDivider() @@ -102,13 +104,13 @@ fun RelaysScreen() { Spacer(Modifier.height(8.dp)) AmberOutlinedButton( - text = "Reconnect relays", + text = Strings.get("d_reconnect_relays", language), onClick = { scope.launch { AmberDesktop.engine.updateFilter() AmberDesktop.client.connect() AmberDesktop.client.reconnect(true) - Toaster.toast("Reconnecting…") + Toaster.toast(Strings.get("d_reconnecting", language)) } }, ) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt index 3697a2df..d30e270a 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt @@ -56,49 +56,50 @@ fun SettingsScreen(account: DesktopAccount) { val scope = rememberCoroutineScope() val settings by SettingsStore.settings.collectAsState() val accounts by AccountsStore.accounts.collectAsState() + val language by Strings.currentLanguage.collectAsState() var showBackupDialog by remember { mutableStateOf(false) } var showLogsDialog by remember { mutableStateOf(false) } var showLogoutConfirm by remember { mutableStateOf(null) } var name by remember { mutableStateOf(account.name.value) } Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - SectionTitle("Account") + SectionTitle(Strings.get("account", language)) Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Name") }, + label = { Text(Strings.get("name", language)) }, singleLine = true, modifier = Modifier.widthIn(max = 420.dp).weight(1f, fill = false), ) AmberOutlinedButton( - text = "Save", + text = Strings.get("save", language), onClick = { account.name.value = name Session.saveMeta(account) - Toaster.toast("Saved") + Toaster.toast(Strings.get("d_saved", language)) }, ) } Text( - "Public key: ${account.npub}", + Strings.format("d_public_key", account.npub, language = language), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - AmberButton(text = "Backup keys", onClick = { showBackupDialog = true }) + AmberButton(text = Strings.get("backup_keys", language), onClick = { showBackupDialog = true }) Spacer(Modifier.height(16.dp)) - SectionTitle("Sign policy") + SectionTitle(Strings.get("d_sign_policy", language)) Text( - "Default policy applied to newly connected applications.", + Strings.get("d_sign_policy_sub", language), style = MaterialTheme.typography.bodySmall, ) Spacer(Modifier.height(4.dp)) val policies = listOf( - 0 to "Basic permissions", - 1 to "Approve requested permissions", - 2 to "Sign everything", + 0 to Strings.get("d_policy_basic", language), + 1 to Strings.get("d_policy_approve", language), + 2 to Strings.get("d_policy_sign_all", language), ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { policies.forEach { (value, label) -> @@ -114,9 +115,13 @@ fun SettingsScreen(account: DesktopAccount) { } Spacer(Modifier.height(16.dp)) - SectionTitle("Theme") + SectionTitle(Strings.get("d_theme", language)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf>(null to "System", false to "Light", true to "Dark").forEach { (value, label) -> + listOf>( + null to Strings.get("d_theme_system", language), + false to Strings.get("d_theme_light", language), + true to Strings.get("d_theme_dark", language), + ).forEach { (value, label) -> FilterChip( selected = settings.darkTheme == value, onClick = { SettingsStore.update { it.copy(darkTheme = value) } }, @@ -126,28 +131,28 @@ fun SettingsScreen(account: DesktopAccount) { } Spacer(Modifier.height(16.dp)) - SectionTitle(Strings.get("language")) + SectionTitle(Strings.get("language", language)) LanguagePicker() Spacer(Modifier.height(16.dp)) - SectionTitle("Desktop") + SectionTitle(Strings.get("d_desktop", language)) if (isTraySupported) { SettingSwitch( - title = "Keep running in the tray", - description = "Closing the window minimizes Amber to the system tray so it keeps answering requests.", + title = Strings.get("d_keep_in_tray", language), + description = Strings.get("d_keep_in_tray_sub", language), checked = settings.closeToTray, onCheckedChange = { value -> SettingsStore.update { it.copy(closeToTray = value) } }, ) } SettingSwitch( - title = "Notifications", - description = "Show a system notification when a request needs your approval.", + title = Strings.get("d_notifications", language), + description = Strings.get("d_notifications_sub", language), checked = settings.showNotifications, onCheckedChange = { value -> SettingsStore.update { it.copy(showNotifications = value) } }, ) Spacer(Modifier.height(16.dp)) - SectionTitle("Accounts") + SectionTitle(Strings.get("accounts", language)) accounts.forEach { record -> Row( Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), @@ -160,7 +165,7 @@ fun SettingsScreen(account: DesktopAccount) { ) if (record.npub == account.npub) { Text( - "Active", + Strings.get("d_active", language), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -168,31 +173,30 @@ fun SettingsScreen(account: DesktopAccount) { } if (record.npub != account.npub) { TextButton(onClick = { scope.launch { Session.switchTo(record.npub) } }) { - Text("Switch") + Text(Strings.get("d_switch", language)) } } TextButton(onClick = { showLogoutConfirm = record.npub }) { - Text("Log out") + Text(Strings.get("d_log_out", language)) } } HorizontalDivider() } Spacer(Modifier.height(8.dp)) - AmberOutlinedButton(text = "Add an account", onClick = { Session.addingAccount.value = true }) + AmberOutlinedButton(text = Strings.get("d_add_an_account", language), onClick = { Session.addingAccount.value = true }) Spacer(Modifier.height(16.dp)) - SectionTitle("Security") + SectionTitle(Strings.get("security", language)) Text( - "Keys are encrypted with AES-256; the encryption key is protected by: " + - (DesktopKeyStore.passwordSourceDescription ?: "…"), + Strings.format("d_security_keys_desc", DesktopKeyStore.passwordSourceDescription ?: "…", language = language), style = MaterialTheme.typography.bodySmall, ) Spacer(Modifier.height(8.dp)) SecuritySection() Spacer(Modifier.height(16.dp)) - SectionTitle("Diagnostics") - AmberOutlinedButton(text = "View logs", onClick = { showLogsDialog = true }) + SectionTitle(Strings.get("d_diagnostics", language)) + AmberOutlinedButton(text = Strings.get("d_view_logs", language), onClick = { showLogsDialog = true }) Spacer(Modifier.height(24.dp)) } @@ -205,18 +209,18 @@ fun SettingsScreen(account: DesktopAccount) { showLogoutConfirm?.let { npub -> AlertDialog( onDismissRequest = { showLogoutConfirm = null }, - title = { Text("Log out?") }, - text = { Text("This deletes the account key and its connections from this device. Make sure the key is backed up.") }, + title = { Text(Strings.get("d_log_out_q", language)) }, + text = { Text(Strings.get("d_log_out_confirm", language)) }, confirmButton = { TextButton( onClick = { showLogoutConfirm = null scope.launch { Session.logout(npub) } }, - ) { Text("Log out") } + ) { Text(Strings.get("d_log_out", language)) } }, dismissButton = { - TextButton(onClick = { showLogoutConfirm = null }) { Text("Cancel") } + TextButton(onClick = { showLogoutConfirm = null }) { Text(Strings.get("cancel", language)) } }, ) } @@ -274,35 +278,40 @@ private fun SecuritySection() { val scope = rememberCoroutineScope() val lockStatus by PassphraseLock.state.collectAsState() val settings by SettingsStore.settings.collectAsState() + val language by Strings.currentLanguage.collectAsState() var dialog by remember { mutableStateOf(null) } var showRemoveConfirm by remember { mutableStateOf(false) } if (lockStatus == PassphraseLock.Status.DISABLED) { Text( - "Set a passphrase to keep your keys encrypted even against software " + - "that can read your files. You will be asked for it when Amber starts.", + Strings.get("d_passphrase_desc", language), style = MaterialTheme.typography.bodySmall, ) - AmberButton(text = "Set a passphrase", onClick = { dialog = PassphraseDialogMode.SET }) + AmberButton(text = Strings.get("d_set_passphrase", language), onClick = { dialog = PassphraseDialogMode.SET }) } else { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { AmberOutlinedButton( - text = "Lock now", + text = Strings.get("d_lock_now", language), onClick = { PassphraseLock.lock() }, ) AmberOutlinedButton( - text = "Change passphrase", + text = Strings.get("d_change_passphrase", language), onClick = { dialog = PassphraseDialogMode.CHANGE }, ) AmberOutlinedButton( - text = "Remove passphrase", + text = Strings.get("d_remove_passphrase", language), onClick = { showRemoveConfirm = true }, ) } Spacer(Modifier.height(8.dp)) - Text("Lock automatically after", style = MaterialTheme.typography.bodySmall) + Text(Strings.get("d_lock_after", language), style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(0 to "Never", 5 to "5 min", 15 to "15 min", 60 to "1 hour").forEach { (minutes, label) -> + listOf( + 0 to Strings.get("d_lock_never", language), + 5 to Strings.get("d_lock_5min", language), + 15 to Strings.get("d_lock_15min", language), + 60 to Strings.get("d_lock_1hour", language), + ).forEach { (minutes, label) -> FilterChip( selected = settings.autoLockMinutes == minutes, onClick = { @@ -321,13 +330,9 @@ private fun SecuritySection() { if (showRemoveConfirm) { AlertDialog( onDismissRequest = { showRemoveConfirm = false }, - title = { Text("Remove the passphrase?") }, + title = { Text(Strings.get("d_remove_passphrase_q", language)) }, text = { - Text( - "The encryption key will go back to the OS credential store " + - "(or a local file), and Amber will no longer ask for a " + - "passphrase at startup.", - ) + Text(Strings.get("d_remove_passphrase_desc", language)) }, confirmButton = { TextButton( @@ -335,13 +340,13 @@ private fun SecuritySection() { showRemoveConfirm = false scope.launch { PassphraseLock.disable() - Toaster.toast("Passphrase removed") + Toaster.toast(Strings.get("d_passphrase_removed", language)) } }, - ) { Text("Remove") } + ) { Text(Strings.get("remove", language)) } }, dismissButton = { - TextButton(onClick = { showRemoveConfirm = false }) { Text("Cancel") } + TextButton(onClick = { showRemoveConfirm = false }) { Text(Strings.get("cancel", language)) } }, ) } @@ -355,6 +360,7 @@ private fun PassphraseDialog( onDismiss: () -> Unit, ) { val scope = rememberCoroutineScope() + val language by Strings.currentLanguage.collectAsState() var current by remember { mutableStateOf("") } var new by remember { mutableStateOf("") } var confirm by remember { mutableStateOf("") } @@ -362,13 +368,11 @@ private fun PassphraseDialog( AlertDialog( onDismissRequest = { if (!working) onDismiss() }, - title = { Text(if (mode == PassphraseDialogMode.SET) "Set a passphrase" else "Change the passphrase") }, + title = { Text(if (mode == PassphraseDialogMode.SET) Strings.get("d_set_passphrase", language) else Strings.get("d_change_the_passphrase", language)) }, text = { Column { Text( - "The passphrase is never stored anywhere. If you forget it, the " + - "only way back in is restoring your keys from a backup (nsec " + - "or seed words).", + Strings.get("d_passphrase_never_stored", language), style = MaterialTheme.typography.bodySmall, ) Spacer(Modifier.height(8.dp)) @@ -376,7 +380,7 @@ private fun PassphraseDialog( OutlinedTextField( value = current, onValueChange = { current = it }, - label = { Text("Current passphrase") }, + label = { Text(Strings.get("d_current_passphrase", language)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), @@ -386,7 +390,7 @@ private fun PassphraseDialog( OutlinedTextField( value = new, onValueChange = { new = it }, - label = { Text("New passphrase (min. 8 characters)") }, + label = { Text(Strings.get("d_new_passphrase", language)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), @@ -395,7 +399,7 @@ private fun PassphraseDialog( OutlinedTextField( value = confirm, onValueChange = { confirm = it }, - label = { Text("Repeat the new passphrase") }, + label = { Text(Strings.get("d_repeat_passphrase", language)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), @@ -407,11 +411,11 @@ private fun PassphraseDialog( enabled = !working, onClick = { if (new.length < 8) { - Toaster.toast("Use at least 8 characters") + Toaster.toast(Strings.get("d_use_8_chars", language)) return@TextButton } if (new != confirm) { - Toaster.toast("The passphrases do not match") + Toaster.toast(Strings.get("d_passphrases_no_match", language)) return@TextButton } working = true @@ -419,26 +423,26 @@ private fun PassphraseDialog( try { if (mode == PassphraseDialogMode.SET) { PassphraseLock.enable(new.toCharArray()) - Toaster.toast("Passphrase set") + Toaster.toast(Strings.get("d_passphrase_set", language)) onDismiss() } else { if (PassphraseLock.changePassphrase(current.toCharArray(), new.toCharArray())) { - Toaster.toast("Passphrase changed") + Toaster.toast(Strings.get("d_passphrase_changed", language)) onDismiss() } else { - Toaster.toast("Wrong current passphrase") + Toaster.toast(Strings.get("d_wrong_current_passphrase", language)) } } } catch (e: Exception) { - Toaster.toast(e.message ?: "Failed to update the passphrase") + Toaster.toast(e.message ?: Strings.get("d_failed_update_passphrase", language)) } working = false } }, - ) { Text(if (working) "Working…" else "Save") } + ) { Text(if (working) Strings.get("d_working", language) else Strings.get("save", language)) } }, dismissButton = { - TextButton(enabled = !working, onClick = onDismiss) { Text("Cancel") } + TextButton(enabled = !working, onClick = onDismiss) { Text(Strings.get("cancel", language)) } }, ) } @@ -450,6 +454,7 @@ private fun BackupDialog( ) { val clipboard = LocalClipboardManager.current val scope = rememberCoroutineScope() + val language by Strings.currentLanguage.collectAsState() var showSecret by remember { mutableStateOf(false) } var password by remember { mutableStateOf("") } var ncryptsec by remember { mutableStateOf("") } @@ -457,15 +462,15 @@ private fun BackupDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Backup keys") }, + title = { Text(Strings.get("backup_keys", language)) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { - Text("Secret key (nsec)", style = MaterialTheme.typography.titleSmall) + Text(Strings.get("d_secret_key_nsec", language), style = MaterialTheme.typography.titleSmall) if (showSecret) { Text(account.getNsec(), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) if (seedWords.isNotBlank()) { Spacer(Modifier.height(8.dp)) - Text("Seed words", style = MaterialTheme.typography.titleSmall) + Text(Strings.get("d_seed_words", language), style = MaterialTheme.typography.titleSmall) Text(seedWords, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) } } else { @@ -474,7 +479,7 @@ private fun BackupDialog( showSecret = true scope.launch { seedWords = AccountManager.seedWords(account.npub) } }, - ) { Text("Show") } + ) { Text(Strings.get("d_show", language)) } } Row { TextButton( @@ -482,17 +487,17 @@ private fun BackupDialog( clipboard.setText(AnnotatedString(account.getNsec())) account.didBackup = true Session.saveMeta(account) - Toaster.toast("Secret key copied. Clear your clipboard after pasting it!") + Toaster.toast(Strings.get("d_nsec_copied", language)) }, - ) { Text("Copy nsec") } + ) { Text(Strings.get("d_copy_nsec", language)) } } Spacer(Modifier.height(12.dp)) - Text("Encrypted backup (ncryptsec)", style = MaterialTheme.typography.titleSmall) + Text(Strings.get("d_encrypted_backup", language), style = MaterialTheme.typography.titleSmall) OutlinedTextField( value = password, onValueChange = { password = it }, - label = { Text("Password") }, + label = { Text(Strings.get("d_password", language)) }, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) @@ -508,20 +513,20 @@ private fun BackupDialog( TextButton( onClick = { if (password.isBlank()) { - Toaster.toast("Password is required") + Toaster.toast(Strings.get("d_password_required", language)) return@TextButton } ncryptsec = account.nip49Encrypt(password) clipboard.setText(AnnotatedString(ncryptsec)) account.didBackup = true Session.saveMeta(account) - Toaster.toast("Encrypted key copied to the clipboard") + Toaster.toast(Strings.get("d_encrypted_key_copied", language)) }, - ) { Text("Encrypt and copy") } + ) { Text(Strings.get("d_encrypt_and_copy", language)) } } }, confirmButton = { - TextButton(onClick = onDismiss) { Text("Close") } + TextButton(onClick = onDismiss) { Text(Strings.get("d_close", language)) } }, ) } @@ -533,14 +538,15 @@ private fun LogsDialog( ) { val store = AmberDesktop.store(account.npub) val logs by store.logs.collectAsState() + val language by Strings.currentLanguage.collectAsState() AlertDialog( onDismissRequest = onDismiss, - title = { Text("Logs") }, + title = { Text(Strings.get("d_logs", language)) }, text = { Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) { if (logs.isEmpty()) { - Text("No logs") + Text(Strings.get("d_no_logs", language)) } logs.sortedByDescending { it.time }.forEach { log -> Text( @@ -557,10 +563,10 @@ private fun LogsDialog( onClick = { store.clearLogs() }, - ) { Text("Clear") } + ) { Text(Strings.get("d_clear", language)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Close") } + TextButton(onClick = onDismiss) { Text(Strings.get("d_close", language)) } }, ) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt index 21bafe3f..cf8bfa79 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt @@ -14,6 +14,7 @@ import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.SignerType +import com.greenart7c3.nostrsigner.desktop.core.Strings import com.greenart7c3.nostrsigner.desktop.core.rememberTypeDisplayOrder import kotlinx.coroutines.flow.MutableStateFlow @@ -163,14 +164,14 @@ fun handleShortcut( val rememberType = UiState.rememberChoiceFor(request.request.id) if (event.isShiftPressed) { AmberDesktop.engine.reject(request, rememberType) - Toaster.toast("Request rejected") + Toaster.toast(Strings.get("d_request_rejected")) } else { AmberDesktop.engine.approve( request, if (request.type == SignerType.CONNECT) RememberType.ALWAYS else rememberType, request.requestedPermissions, ) - Toaster.toast("Request approved") + Toaster.toast(Strings.get("d_request_approved")) } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt index 54e1ff42..369a747b 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,11 +26,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock +import com.greenart7c3.nostrsigner.desktop.core.Strings import kotlinx.coroutines.launch @Composable fun UnlockScreen() { val scope = rememberCoroutineScope() + val language by Strings.currentLanguage.collectAsState() var passphrase by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } var working by remember { mutableStateOf(false) } @@ -41,7 +44,7 @@ fun UnlockScreen() { scope.launch { val ok = PassphraseLock.unlock(passphrase.toCharArray()) if (!ok) { - error = "Wrong passphrase" + error = Strings.get("d_wrong_passphrase", language) } else { passphrase = "" } @@ -59,12 +62,12 @@ fun UnlockScreen() { style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, ) - Text("Locked", style = MaterialTheme.typography.bodyMedium) + Text(Strings.get("d_locked", language), style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(24.dp)) OutlinedTextField( value = passphrase, onValueChange = { passphrase = it }, - label = { Text("Passphrase") }, + label = { Text(Strings.get("d_passphrase", language)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -76,7 +79,7 @@ fun UnlockScreen() { } Spacer(Modifier.height(16.dp)) AmberButton( - text = if (working) "Unlocking…" else "Unlock", + text = if (working) Strings.get("d_unlocking", language) else Strings.get("d_unlock", language), fillWidth = true, enabled = passphrase.isNotEmpty() && !working, onClick = ::submit, diff --git a/desktop/src/main/resources/i18n/strings_en.xml b/desktop/src/main/resources/i18n/strings_en.xml index 5f50f0f7..1e90c30f 100644 --- a/desktop/src/main/resources/i18n/strings_en.xml +++ b/desktop/src/main/resources/i18n/strings_en.xml @@ -754,4 +754,153 @@ Repository state announcements wants to switch relays sent an invalid request + + Nostr event signer + Incoming requests + Approve or reject what applications ask this signer to do + Connected applications and their permissions + Default relays used by bunker connections + Account, security and application preferences + Switch account + Add an account + No requests waiting for approval + Connect an application from the Applications tab and its signing requests will show up here. + Working… + Approve + Request approved + Request rejected + ↑↓ select · ←→ remember · %1$s approve · %2$s reject + Remember: %1$s + QR code + Saved + Public key: %1$s + Sign policy + Default policy applied to newly connected applications. + Basic permissions + Approve requested permissions + Sign everything + Theme + System + Light + Dark + Desktop + Keep running in the tray + Closing the window minimizes Amber to the system tray so it keeps answering requests. + Notifications + Show a system notification when a request needs your approval. + Active + Switch + Log out + Keys are encrypted with AES-256; the encryption key is protected by: %1$s + Diagnostics + View logs + Log out? + This deletes the account key and its connections from this device. Make sure the key is backed up. + Set a passphrase to keep your keys encrypted even against software that can read your files. You will be asked for it when Amber starts. + Set a passphrase + Lock now + Change passphrase + Remove passphrase + Lock automatically after + Never + 5 min + 15 min + 1 hour + Remove the passphrase? + The encryption key will go back to the OS credential store (or a local file), and Amber will no longer ask for a passphrase at startup. + Passphrase removed + Change the passphrase + The passphrase is never stored anywhere. If you forget it, the only way back in is restoring your keys from a backup (nsec or seed words). + Current passphrase + New passphrase (min. 8 characters) + Repeat the new passphrase + Use at least 8 characters + The passphrases do not match + Passphrase set + Passphrase changed + Wrong current passphrase + Failed to update the passphrase + Secret key (nsec) + Seed words + Show + Copy nsec + Secret key copied. Clear your clipboard after pasting it! + Encrypted backup (ncryptsec) + Password + Encrypt and copy + Password is required + Encrypted key copied to the clipboard + Logs + No logs + Clear + Close + Add a key + Create a new key + nsec, ncryptsec, hex key or seed words + Adding… + Add key + Invalid key + Name (optional) + Your seed words + Write them down and keep them safe. They are the only backup of your new key. + Creating… + Create account + Failed to create the key + Locked + Passphrase + Wrong passphrase + Unlocking… + Unlock + Connect with nostrconnect:// + Create a bunker connection + No applications connected yet + Connected + Waiting for the app to connect + %1$s permissions + last used %1$s + Add an application + Paste the nostrconnect:// URI shown by the application. + Invalid nostrconnect URI + Add a nsecBunker + Bunker connection created + Creates a bunker:// URI you can paste (or scan) in the client application. + Application name + Create + Name is required + Copied to the clipboard + Back + Key: %1$s + Relays: %1$s + Application updated + Activity + always + until %1$s + expired + Accept + Delete permission + No activity yet + kind %1$s + accepted + rejected + Disconnect and delete application + Application removed + wss://relay.example.com + Invalid relay URL + Remove relay + At least one relay is required + Reconnect relays + Reconnecting… + Just once + 1 minute + 5 minutes + 10 minutes + Always + 1 hour + 1 day + 1 week + Amber — Nostr signer + Amber — %1$s pending request(s) + Hide Amber + Open Amber + Quit Amber diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/StringsTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/StringsTest.kt index 9e98071b..b5bcca99 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/StringsTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/StringsTest.kt @@ -1,5 +1,6 @@ package com.greenart7c3.nostrsigner.desktop +import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.SignerDescriptions import com.greenart7c3.nostrsigner.desktop.core.Strings import org.junit.Assert.assertEquals @@ -57,6 +58,24 @@ class StringsTest { assertTrue("should mention the kind number", text.contains("987654")) } + @Test + fun desktopUiKeysResolveInEnglishAndFallBack() { + // Desktop-only supplement keys exist in English... + assertEquals("Approve", Strings.get("d_approve", "en")) + assertEquals("Incoming requests", Strings.get("d_route_incoming_title", "en")) + // ...and, absent from the translated files, fall back to the English text. + assertEquals(Strings.get("d_approve", "en"), Strings.get("d_approve", "de")) + assertEquals(Strings.get("d_route_incoming_title", "en"), Strings.get("d_route_incoming_title", "ja")) + } + + @Test + fun rememberTypeLabelsAreLocalized() { + assertEquals("Just once", RememberType.NEVER.label("en")) + assertEquals("Always", RememberType.ALWAYS.label("en")) + assertEquals("1 day", RememberType.ONE_DAY.label("en")) + assertEquals("1 week", RememberType.ONE_WEEK.label("en")) + } + @Test fun permissionTypesAreLocalized() { assertEquals("Read your public key", SignerDescriptions.permission("get_public_key", null, "en"))