diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts
index d93e36172..427ed28b4 100644
--- a/commons/build.gradle.kts
+++ b/commons/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.mokoResources)
}
android {
@@ -75,6 +76,10 @@ kotlin {
// Immutable collections
api(libs.kotlinx.collections.immutable)
+
+ // Moko Resources for KMP string resources
+ api(libs.moko.resources)
+ api(libs.moko.resources.compose)
}
}
@@ -124,3 +129,8 @@ kotlin {
}
}
}
+
+multiplatformResources {
+ resourcesPackage.set("com.vitorpamplona.amethyst.commons")
+ resourcesClassName.set("SharedRes")
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt
index b549466dd..2ed9eb625 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt
@@ -34,6 +34,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.SharedRes
+import dev.icerock.moko.resources.compose.stringResource
/**
* A centered loading state with a progress indicator and message.
@@ -67,8 +69,10 @@ fun EmptyState(
modifier: Modifier = Modifier,
description: String? = null,
onRefresh: (() -> Unit)? = null,
- refreshLabel: String = "Refresh",
+ refreshLabel: String? = null,
) {
+ val actualRefreshLabel = refreshLabel ?: stringResource(SharedRes.strings.action_refresh)
+
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -90,7 +94,7 @@ fun EmptyState(
if (onRefresh != null) {
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = onRefresh) {
- Text(refreshLabel)
+ Text(actualRefreshLabel)
}
}
}
@@ -104,8 +108,10 @@ fun ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
- retryLabel: String = "Try Again",
+ retryLabel: String? = null,
) {
+ val actualRetryLabel = retryLabel ?: stringResource(SharedRes.strings.action_try_again)
+
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -119,7 +125,7 @@ fun ErrorState(
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) {
- Text(retryLabel)
+ Text(actualRetryLabel)
}
}
}
@@ -131,11 +137,13 @@ fun ErrorState(
@Composable
fun FeedEmptyState(
modifier: Modifier = Modifier,
- title: String = "Feed is empty",
+ title: String? = null,
onRefresh: (() -> Unit)? = null,
) {
+ val actualTitle = title ?: stringResource(SharedRes.strings.feed_empty)
+
EmptyState(
- title = title,
+ title = actualTitle,
modifier = modifier,
onRefresh = onRefresh,
)
@@ -150,8 +158,10 @@ fun FeedErrorState(
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
+ val formattedMessage = stringResource(SharedRes.strings.error_loading_feed).format(errorMessage)
+
ErrorState(
- message = "Error loading feed: $errorMessage",
+ message = formattedMessage,
modifier = modifier,
onRetry = onRetry,
)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt
index 4048a5c02..3a30ba107 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt
@@ -28,6 +28,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.SharedRes
+import dev.icerock.moko.resources.compose.stringResource
/**
* Generic placeholder screen with title and description.
@@ -58,8 +60,8 @@ fun PlaceholderScreen(
@Composable
fun SearchPlaceholder(modifier: Modifier = Modifier) {
PlaceholderScreen(
- title = "Search",
- description = "Search for users, notes, and hashtags.",
+ title = stringResource(SharedRes.strings.screen_search_title),
+ description = stringResource(SharedRes.strings.screen_search_description),
modifier = modifier,
)
}
@@ -70,8 +72,8 @@ fun SearchPlaceholder(modifier: Modifier = Modifier) {
@Composable
fun MessagesPlaceholder(modifier: Modifier = Modifier) {
PlaceholderScreen(
- title = "Messages",
- description = "Your encrypted direct messages will appear here.",
+ title = stringResource(SharedRes.strings.screen_messages_title),
+ description = stringResource(SharedRes.strings.screen_messages_description),
modifier = modifier,
)
}
@@ -82,8 +84,8 @@ fun MessagesPlaceholder(modifier: Modifier = Modifier) {
@Composable
fun NotificationsPlaceholder(modifier: Modifier = Modifier) {
PlaceholderScreen(
- title = "Notifications",
- description = "Mentions, replies, and reactions will appear here.",
+ title = stringResource(SharedRes.strings.screen_notifications_title),
+ description = stringResource(SharedRes.strings.screen_notifications_description),
modifier = modifier,
)
}
diff --git a/commons/src/commonMain/moko-resources/base/strings.xml b/commons/src/commonMain/moko-resources/base/strings.xml
new file mode 100644
index 000000000..7403080ba
--- /dev/null
+++ b/commons/src/commonMain/moko-resources/base/strings.xml
@@ -0,0 +1,53 @@
+
+
+
+ Welcome to Amethyst
+ Sign in to your Nostr account
+ A Nostr client for desktop
+ Login with your Nostr key
+ Use nsec for full access or npub for read-only
+ Login with Key
+ Login
+ Generate New Key
+ Generate New
+ Enter your private key (nsec) or public key (npub)
+ nsec or npub
+ nsec1... or npub1...
+ Show key
+ Hide key
+
+
+ IMPORTANT: Save your keys!
+ Your secret key (nsec) is the ONLY way to access your account. If you lose it, your account is gone forever. Save it somewhere safe!
+ Public Key (shareable):
+ Secret Key (NEVER share this!):
+ I've saved my keys, continue
+
+
+ Copy
+ Paste
+ Cancel
+ OK
+ Save
+ Delete
+ Share
+
+
+ Invalid key format. Please check and try again.
+ Network error. Please check your connection.
+ An error occurred. Please try again.
+
+
+ Refresh
+ Try Again
+ Feed is empty
+ Error loading feed: %s
+
+
+ Search
+ Search for users, notes, and hashtags.
+ Messages
+ Your encrypted direct messages will appear here.
+ Notifications
+ Mentions, replies, and reactions will appear here.
+
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt
index e27629c9f..21e0c4b94 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt
@@ -41,6 +41,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.SharedRes
+import dev.icerock.moko.resources.compose.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* Text field for entering Nostr keys (nsec or npub) with visibility toggle.
@@ -50,8 +53,8 @@ fun KeyInputField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
- label: String = "nsec or npub",
- placeholder: String = "nsec1... or npub1...",
+ label: String = stringResource(SharedRes.strings.login_key_label),
+ placeholder: String = stringResource(SharedRes.strings.login_key_placeholder),
errorMessage: String? = null,
) {
var showKey by remember { mutableStateOf(false) }
@@ -73,7 +76,7 @@ fun KeyInputField(
IconButton(onClick = { showKey = !showKey }) {
Icon(
if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
- contentDescription = if (showKey) "Hide key" else "Show key",
+ contentDescription = if (showKey) stringResource(SharedRes.strings.login_hide_key) else stringResource(SharedRes.strings.login_show_key),
)
}
},
@@ -108,3 +111,20 @@ fun SelectableKeyText(
)
}
}
+
+@Preview
+@Composable
+fun KeyInputFieldPreview() {
+ KeyInputField(
+ value = "nsec1example1234567890",
+ onValueChange = {},
+ )
+}
+
+@Preview
+@Composable
+fun SelectableKeyTextPreview() {
+ SelectableKeyText(
+ key = "npub1example1234567890abcdefghijklmnopqrstuvwxyz1234567890",
+ )
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt
index a80912b91..f97e9179b 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt
@@ -43,6 +43,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.SharedRes
+import dev.icerock.moko.resources.compose.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* Login card with Nostr key input field and action buttons.
@@ -60,8 +63,8 @@ fun LoginCard(
onGenerateNew: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 400.dp,
- title: String = "Login with your Nostr key",
- subtitle: String = "Use nsec for full access or npub for read-only",
+ title: String = stringResource(SharedRes.strings.login_card_title),
+ subtitle: String = stringResource(SharedRes.strings.login_card_subtitle),
) {
var keyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf(null) }
@@ -118,16 +121,25 @@ fun LoginCard(
modifier = Modifier.weight(1f),
enabled = keyInput.isNotBlank(),
) {
- Text("Login")
+ Text(stringResource(SharedRes.strings.login_button))
}
OutlinedButton(
onClick = onGenerateNew,
modifier = Modifier.weight(1f),
) {
- Text("Generate New")
+ Text(stringResource(SharedRes.strings.login_generate_button))
}
}
}
}
}
+
+@Preview
+@Composable
+fun LoginCardPreview() {
+ LoginCard(
+ onLogin = { Result.success(Unit) },
+ onGenerateNew = {},
+ )
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt
index 4fa976aa9..9952ee4fb 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt
@@ -36,6 +36,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.SharedRes
+import dev.icerock.moko.resources.compose.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* Warning card displayed after generating a new Nostr key pair.
@@ -66,7 +69,7 @@ fun NewKeyWarningCard(
modifier = Modifier.padding(24.dp),
) {
Text(
- "IMPORTANT: Save your keys!",
+ stringResource(SharedRes.strings.new_key_warning_title),
style = MaterialTheme.typography.titleMedium,
color = Color.Red,
)
@@ -74,8 +77,7 @@ fun NewKeyWarningCard(
Spacer(Modifier.height(16.dp))
Text(
- "Your secret key (nsec) is the ONLY way to access your account. " +
- "If you lose it, your account is gone forever. Save it somewhere safe!",
+ stringResource(SharedRes.strings.new_key_warning_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -83,7 +85,7 @@ fun NewKeyWarningCard(
Spacer(Modifier.height(16.dp))
Text(
- "Public Key (shareable):",
+ stringResource(SharedRes.strings.new_key_public_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -93,7 +95,7 @@ fun NewKeyWarningCard(
nsec?.let { secretKey ->
Text(
- "Secret Key (NEVER share this!):",
+ stringResource(SharedRes.strings.new_key_secret_label),
style = MaterialTheme.typography.labelMedium,
color = Color.Red,
)
@@ -106,8 +108,18 @@ fun NewKeyWarningCard(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
- Text("I've saved my keys, continue")
+ Text(stringResource(SharedRes.strings.new_key_continue_button))
}
}
}
}
+
+@Preview
+@Composable
+fun NewKeyWarningCardPreview() {
+ NewKeyWarningCard(
+ npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz",
+ nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz",
+ onContinue = {},
+ )
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt
index f194564ae..be84b6375 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt
@@ -37,10 +37,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.SharedRes
import com.vitorpamplona.amethyst.commons.account.AccountManager
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard
import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard
+import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -59,7 +61,7 @@ fun LoginScreen(
verticalArrangement = Arrangement.Center,
) {
Text(
- "Welcome to Amethyst",
+ stringResource(SharedRes.strings.login_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
)
@@ -67,7 +69,7 @@ fun LoginScreen(
Spacer(Modifier.height(8.dp))
Text(
- "A Nostr client for desktop",
+ stringResource(SharedRes.strings.login_subtitle_desktop),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0918278b9..5717cae99 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -40,6 +40,7 @@ lightcompressor-enhanced = "1.6.0"
markdown = "f92ef49c9d"
media3 = "1.9.0"
mockk = "1.14.7"
+mokoResources = "0.25.2"
kotlinx-coroutines-test = "1.10.2"
navigationCompose = "2.9.6"
okhttp = "5.3.2"
@@ -135,6 +136,8 @@ markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "ric
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
+moko-resources = { group = "dev.icerock.moko", name = "resources", version.ref = "mokoResources" }
+moko-resources-compose = { group = "dev.icerock.moko", name = "resources-compose", version.ref = "mokoResources" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
@@ -173,3 +176,4 @@ androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.lib
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }