diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 9a3def24..ed48d520 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -272,4 +272,45 @@
You will be in full control of this profile. If you would like to use it for the long term please remember to backup the profile/keys.
Your profile is almost ready... just getting it's first cryptographic signature together.
Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.
+
+ Add chapter to %1$s
+ Add people to %1$s
+ Chapter %1$s
+ Chapter %1$s · %2$s words · %3$s characters
+ Chunk %1$s
+ Chunks (%1$s)
+ %1$s/%2$s chunks translated
+ %1$s event(s) still unreadable%2$s
+ %1$s events, signed together
+ Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part.
+ "%1$s" functionality coming soon
+ How should %1$s be run?
+ Invite %1$s
+ %1$s Key Packages
+ Next with %1$s
+ nostr:%1$s..
+ nostr:%1$s...
+ %1$s of %2$s
+ %1$s of %2$s members will be needed to sign with this key.
+ %1$s of them could not be read
+ Once invited will be able to receive and send private message sent to all the %1$s members in the chat room.
+ Private message to %1$s
+ Private to %1$s
+ %1$s proposals are waiting for your signature
+ Recovered %1$s of %2$s event(s)%3$s
+ Recovered %1$s of %2$s · %3$s still unreadable%4$s
+ Reply privately to %1$s
+ Reply to %1$s
+ replying To %1$s
+ Searching for %1$s on "%2$s"
+ %1$s selected
+ %1$s sent a private message to %2$s
+ Shared key for %1$s
+ to join the %1$s chat room.
+ Translate %1$s
+ Unsupported event kind: %1$s
+ %1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Torch.
+ %1$s was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.
+ %1$s words · %2$s characters
+ You and %1$s others
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt
index 71290ac7..fe355e7a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt
@@ -63,6 +63,7 @@ import mantra.composeapp.generated.resources.original_text_markdown
import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_lines
import mantra.composeapp.generated.resources.propose_chapter
import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter
+import mantra.composeapp.generated.resources.add_chapter_to
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -138,7 +139,7 @@ fun AddChapterScreen(
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
- title = { Text("Add chapter to ${addChapterUIState.artifact.name}") },
+ title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt
index 4f90886b..32cee011 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt
@@ -43,6 +43,9 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.direct_message_detail
import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here
import mantra.composeapp.generated.resources.invite
+import mantra.composeapp.generated.resources.invite_2
+import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receive_and
+import mantra.composeapp.generated.resources.to_join_the_chat_room
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -119,7 +122,7 @@ fun AddMemberToChatRoomConfirmationScreen(
contentDescription = "Invite new member"
)
- Text("Invite ${addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()}")
+ Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()))
}
},
actions = {
@@ -147,11 +150,11 @@ fun AddMemberToChatRoomConfirmationScreen(
)
Text(
- text = "to join the ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: ""} chat room."
+ text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "")
)
Text(
- text = "Once invited will be able to receive and send private message sent to all the ${addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size} members in the chat room.",
+ text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size),
textAlign = TextAlign.Center
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt
index c8c35fa3..96eb42d8 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt
@@ -64,6 +64,7 @@ import mantra.composeapp.generated.resources.add_translation
import mantra.composeapp.generated.resources.propose_translation
import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a
import mantra.composeapp.generated.resources.translate_into_which_dialect
+import mantra.composeapp.generated.resources.translate
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -137,7 +138,7 @@ fun AddTranslationArtifactVersionScreen(
Scaffold(
topBar = {
TopAppBar(
- title = { Text("Translate ${addTranslationUIState.artifact.name}") },
+ title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt
index 13eaf2a4..96de40d5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt
@@ -62,6 +62,8 @@ import mantra.composeapp.generated.resources.no_versions
import mantra.composeapp.generated.resources.translations
import mantra.composeapp.generated.resources.version
import mantra.composeapp.generated.resources.versions
+import mantra.composeapp.generated.resources.chapter
+import mantra.composeapp.generated.resources.words_characters
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -151,10 +153,10 @@ fun ArtifactDetailScreen(
contentDescription = "Chapter"
)
},
- overlineContent = { Text("Chapter ${chapter.index}") },
+ overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) },
headlineContent = { Text(chapter.name) },
supportingContent = {
- Text("${chapter.wordCount} words · ${chapter.characterCount} characters")
+ Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount))
}
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt
index 99d5b6fc..44994bc7 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt
@@ -44,6 +44,10 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.chapter_detail
import mantra.composeapp.generated.resources.no_chunks
import mantra.composeapp.generated.resources.original_text
+import mantra.composeapp.generated.resources.chapter_words_characters
+import mantra.composeapp.generated.resources.chunk
+import mantra.composeapp.generated.resources.chunks
+import mantra.composeapp.generated.resources.words_characters
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -101,7 +105,7 @@ fun ChapterDetailScreen(
) {
item {
Text(
- text = "Chapter ${chapter.index} · ${chapter.wordCount} words · ${chapter.characterCount} characters",
+ text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount),
style = MaterialTheme.typography.labelMedium
)
}
@@ -128,7 +132,7 @@ fun ChapterDetailScreen(
// Chunks
item {
Text(
- text = "Chunks (${chapterDetailUIState.chunks.size})",
+ text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size),
style = MaterialTheme.typography.labelMedium
)
}
@@ -147,10 +151,10 @@ fun ChapterDetailScreen(
contentDescription = "Chunk"
)
},
- overlineContent = { Text("Chunk ${chunk.index}") },
+ overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) },
headlineContent = { Text(chunk.text) },
supportingContent = {
- Text("${chunk.wordCount} words · ${chunk.characterCount} characters")
+ Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount))
}
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index aa4b439b..24e11a6a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
@@ -89,6 +89,9 @@ import mantra.composeapp.generated.resources.projects
import mantra.composeapp.generated.resources.proposals
import mantra.composeapp.generated.resources.reindex_events
import mantra.composeapp.generated.resources.shared_key
+import mantra.composeapp.generated.resources.event_s_still_unreadable
+import mantra.composeapp.generated.resources.recovered_of_event_s
+import mantra.composeapp.generated.resources.recovered_of_still_unreadable
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -706,10 +709,10 @@ private fun ReindexMarmotGroupEventsButton(
"Nothing to reindex · ${report.stored - report.predatingMembership} " +
"event(s) all read$beforeJoining"
report.recovered > 0 && report.failed > 0 ->
- "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable$beforeJoining"
+ stringResource(Res.string.recovered_of_still_unreadable, report.recovered, report.unresolved, report.failed, beforeJoining)
report.recovered > 0 ->
- "Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining"
- else -> "${report.failed} event(s) still unreadable$beforeJoining"
+ stringResource(Res.string.recovered_of_event_s, report.recovered, report.unresolved, beforeJoining)
+ else -> stringResource(Res.string.event_s_still_unreadable, report.failed, beforeJoining)
},
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt
index aa0ed424..f4bcda21 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt
@@ -64,6 +64,8 @@ import mantra.composeapp.generated.resources.creating_new_chat
import mantra.composeapp.generated.resources.direct_message_detail
import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here
import mantra.composeapp.generated.resources.say_what_now
+import mantra.composeapp.generated.resources.private_message_to
+import mantra.composeapp.generated.resources.private_to
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -242,7 +244,7 @@ fun ChatRoomMessagingScreen(
Text(
modifier = Modifier.weight(1f),
- text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}",
+ text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelMedium
)
@@ -284,7 +286,7 @@ fun ChatRoomMessagingScreen(
placeholder = {
Text(
text = if (directMessageRecipient != null) {
- "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}"
+ stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey))
} else {
stringResource(Res.string.say_what_now)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
index 56d52939..6973c43d 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
@@ -95,6 +95,10 @@ import mantra.composeapp.generated.resources.the_group_has_a_shared_key
import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs
import mantra.composeapp.generated.resources.try_again
import mantra.composeapp.generated.resources.your_share_of_it_is_on_this_device_only_your
+import mantra.composeapp.generated.resources.everyone_has_to_be_online_at_the_same_time
+import mantra.composeapp.generated.resources.of
+import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_with_this
+import mantra.composeapp.generated.resources.shared_key_for
/**
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
@@ -148,7 +152,7 @@ fun DkgRitualScreen(
TopAppBar(
title = {
Text(
- text = "Shared key for ${dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"}",
+ text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -222,7 +226,7 @@ fun DkgRitualScreen(
actions = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
- text = "${dkgRitualViewModel.threshold.value} of $participantCount",
+ text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount),
style = MaterialTheme.typography.labelLarge
)
}
@@ -245,7 +249,7 @@ fun DkgRitualScreen(
)
Text(
- text = "Everyone has to be online at the same time — the ceremony can only finish once all $participantCount of you have taken part.",
+ text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount),
style = MaterialTheme.typography.bodyMedium
)
@@ -369,7 +373,7 @@ private fun RitualProgress(
}
Text(
- text = "${session.threshold} of $participantCount members will be needed to sign with this key.",
+ text = stringResource(Res.string.of_members_will_be_needed_to_sign_with_this, session.threshold, participantCount),
style = MaterialTheme.typography.bodyMedium
)
@@ -606,7 +610,7 @@ private fun RitualStep(
Text(text = title, style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.weight(1f))
Text(
- text = "$count of $total",
+ text = stringResource(Res.string.of, count, total),
style = MaterialTheme.typography.labelLarge
)
}
@@ -651,7 +655,7 @@ private fun QuorumStepper(
}
Text(
- text = "$threshold of $participantCount",
+ text = stringResource(Res.string.of, threshold, participantCount),
style = MaterialTheme.typography.titleMedium
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt
index 08d39f6f..3bff2f5b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt
@@ -68,6 +68,7 @@ import mantra.composeapp.generated.resources.ready_to_sign
import mantra.composeapp.generated.resources.sign
import mantra.composeapp.generated.resources.sign_with_the_group_s_key
import mantra.composeapp.generated.resources.signed_their_part
+import mantra.composeapp.generated.resources.events_signed_together
/**
* One signing session, and the member's decision about it.
@@ -352,7 +353,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)) {
if (events.size > 1) {
Text(
- text = "${events.size} events, signed together",
+ text = stringResource(Res.string.events_signed_together, events.size),
style = MaterialTheme.typography.labelMedium
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt
index 049cf200..f9102c41 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt
@@ -14,6 +14,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import press.mantra.compose.ui.theme.spacing
+import mantra.composeapp.generated.resources.Res
+import org.jetbrains.compose.resources.stringResource
+import mantra.composeapp.generated.resources.functionality_coming_soon_2
@Composable
fun ImplementationPendingScreen(
@@ -30,7 +33,7 @@ fun ImplementationPendingScreen(
verticalArrangement = Arrangement.Center
) {
Text(
- "\"$text\" functionality coming soon",
+ stringResource(Res.string.functionality_coming_soon_2, text),
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt
index b3e3f4cd..ba863c18 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt
@@ -50,6 +50,7 @@ import mantra.composeapp.generated.resources.key_package_management
import mantra.composeapp.generated.resources.publish_new_key_package
import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please
+import mantra.composeapp.generated.resources.key_packages
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -135,7 +136,7 @@ fun KeyPackageManagementScreen(
}
item {
Text(
- "${keyPackageManagementUIState.keyPackageBundles.size} Key Packages"
+ stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size)
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt
index aab513c5..d3795056 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt
@@ -62,6 +62,7 @@ import mantra.composeapp.generated.resources.proposals
import mantra.composeapp.generated.resources.review
import mantra.composeapp.generated.resources.this_group_has_not_been_asked_to_sign
import mantra.composeapp.generated.resources.waiting_for_you
+import mantra.composeapp.generated.resources.of_them_could_not_be_read
/**
* Everything the group has asked its shared key to sign.
@@ -340,7 +341,7 @@ private fun ProposalCard(
// rather than left for the screen behind it.
if (proposal.unreadable > 0) {
Text(
- text = "${proposal.unreadable} of them could not be read",
+ text = stringResource(Res.string.of_them_could_not_be_read, proposal.unreadable),
color = if (proposal.awaitsYou) {
cardContentColor
} else {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt
index 7a096b57..4b80749c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt
@@ -61,6 +61,7 @@ import mantra.composeapp.generated.resources.no_events_were_found
import mantra.composeapp.generated.resources.profiles
import mantra.composeapp.generated.resources.search
import mantra.composeapp.generated.resources.something_went_wrong
+import mantra.composeapp.generated.resources.searching_for_on
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -273,7 +274,7 @@ fun SearchResultScreen(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
- text = "Searching for ${searchViewModel.searchResultType.name.lowercase()} on \"${searchQuery.lowercase()}\"",
+ text = stringResource(Res.string.searching_for_on, searchViewModel.searchResultType.name.lowercase(), searchQuery.lowercase()),
textAlign = TextAlign.Center
)
Spacer(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt
index e115d788..58bcbc7b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt
@@ -50,6 +50,9 @@ import mantra.composeapp.generated.resources.no_one_selected_yet
import mantra.composeapp.generated.resources.no_one_to_add_yet
import mantra.composeapp.generated.resources.search_for_people_and_chat_with_them_first
import mantra.composeapp.generated.resources.you_can_still_carry_on_and_invite_people
+import mantra.composeapp.generated.resources.add_people_to
+import mantra.composeapp.generated.resources.next_with
+import mantra.composeapp.generated.resources.selected
/**
* Second step of group creation: pick who is in the group.
@@ -101,7 +104,7 @@ fun SelectChatRoomMembersScreen(
TopAppBar(
title = {
Text(
- text = "Add people to $name",
+ text = stringResource(Res.string.add_people_to, name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -125,7 +128,7 @@ fun SelectChatRoomMembersScreen(
Text(
text = if (selectedCount > 0) {
- "Next with $selectedCount"
+ stringResource(Res.string.next_with, selectedCount)
} else {
stringResource(Res.string.next)
}
@@ -136,7 +139,7 @@ fun SelectChatRoomMembersScreen(
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = if (selectedCount > 0) {
- "$selectedCount selected"
+ stringResource(Res.string.selected, selectedCount)
} else {
stringResource(Res.string.no_one_selected_yet)
},
@@ -247,7 +250,7 @@ fun SelectChatRoomMembersScreen(
)
Text(
- text = "Add people to $name",
+ text = stringResource(Res.string.add_people_to, name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt
index 3831e0c8..db33c18d 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt
@@ -65,6 +65,11 @@ import mantra.composeapp.generated.resources.just_you_for_now
import mantra.composeapp.generated.resources.open_chat
import mantra.composeapp.generated.resources.this_decides_who_can_change_the_group_later
import mantra.composeapp.generated.resources.you_and_1_other
+import mantra.composeapp.generated.resources.how_should_be_run
+import mantra.composeapp.generated.resources.of
+import mantra.composeapp.generated.resources.was_created_but_couldn_t_be_added_yet_invite
+import mantra.composeapp.generated.resources.was_created_but_its_shared_key_ceremony
+import mantra.composeapp.generated.resources.you_and_others
/**
* Last step of group creation: convenient (one admin) or robust (everyone
@@ -127,7 +132,7 @@ fun SelectChatRoomTypeScreen(
TopAppBar(
title = {
Text(
- text = "How should $name be run?",
+ text = stringResource(Res.string.how_should_be_run, name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -170,7 +175,7 @@ fun SelectChatRoomTypeScreen(
text = when (selectChatRoomTypeUIState.members.size) {
0 -> stringResource(Res.string.just_you_for_now)
1 -> stringResource(Res.string.you_and_1_other)
- else -> "You and ${selectChatRoomTypeUIState.members.size} others"
+ else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size)
},
style = MaterialTheme.typography.labelLarge
)
@@ -196,7 +201,7 @@ fun SelectChatRoomTypeScreen(
) {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200),
- text = "$name was created, but ${membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }} couldn't be added yet. Invite them again from the chat once they're on Torch.",
+ text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }),
style = MaterialTheme.typography.bodyMedium
)
}
@@ -212,7 +217,7 @@ fun SelectChatRoomTypeScreen(
) {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200),
- text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.",
+ text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name),
style = MaterialTheme.typography.bodyMedium
)
}
@@ -287,7 +292,7 @@ fun SelectChatRoomTypeScreen(
)
Text(
- text = "How should $name be run?",
+ text = stringResource(Res.string.how_should_be_run, name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
@@ -428,7 +433,7 @@ private fun QuorumPicker(
}
Text(
- text = "$quorum of $adminCount",
+ text = stringResource(Res.string.of, quorum, adminCount),
style = MaterialTheme.typography.titleMedium
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt
index 9dacc18e..6b10e683 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt
@@ -55,6 +55,8 @@ import mantra.composeapp.generated.resources.no_chapters
import mantra.composeapp.generated.resources.propose
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot
import mantra.composeapp.generated.resources.translation_detail
+import mantra.composeapp.generated.resources.chapter
+import mantra.composeapp.generated.resources.chunks_translated
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -217,9 +219,9 @@ fun TranslationArtifactVersionDetailScreen(
contentDescription = "Chapter"
)
},
- headlineContent = { Text("Chapter ${progress.chapter.index}") },
+ headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) },
supportingContent = {
- Text("${progress.translatedChunks}/${progress.totalChunks} chunks translated")
+ Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks))
}
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt
index 8a6469b4..86313d71 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt
@@ -55,6 +55,7 @@ import mantra.composeapp.generated.resources.type_out_what_you_would_like_to_pub
import mantra.composeapp.generated.resources.what_s_your_comment_on_the_below
import mantra.composeapp.generated.resources.what_s_your_reply_to_the_above
import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send_out
+import mantra.composeapp.generated.resources.replying_to
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
@@ -130,7 +131,7 @@ fun WriteNewNoteScreen(
bottom = MaterialTheme.spacing.space125,
),
- text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}",
+ text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -346,7 +347,7 @@ fun WriteNewNoteScreen(
if (writeNewNoteUIState.inReplyToNostrEvent != null) {
Text(
- text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}",
+ text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt
index b2a614d2..d80f0a98 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt
@@ -32,6 +32,10 @@ import co.touchlab.kermit.Logger
import coil3.compose.AsyncImage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.theme.spacing
+import mantra.composeapp.generated.resources.Res
+import org.jetbrains.compose.resources.stringResource
+import mantra.composeapp.generated.resources.nostr
+import mantra.composeapp.generated.resources.nostr_2
@Composable
fun RichContent(
@@ -284,7 +288,7 @@ fun RichContent(
)
} else {
Text(
- text = "nostr:${segment.eventId.take(8)}..",
+ text = stringResource(Res.string.nostr, segment.eventId.take(8)),
style = style,
color = MaterialTheme.colorScheme.primary
)
@@ -308,7 +312,7 @@ fun RichContent(
)
} else {
Text(
- text = "nostr:${segment.dTag.take(12)}...",
+ text = stringResource(Res.string.nostr_2, segment.dTag.take(12)),
style = style,
color = MaterialTheme.colorScheme.primary
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt
index 95410c3c..78716cd3 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt
@@ -15,6 +15,7 @@ import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.unsupported_event_kind
+import mantra.composeapp.generated.resources.unsupported_event_kind_2
@Composable
internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) {
@@ -27,7 +28,7 @@ internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) {
.padding(vertical = MaterialTheme.spacing.space75)
) {
Text(
- text = if (kind != null) "Unsupported event kind: $kind" else stringResource(Res.string.unsupported_event_kind),
+ text = if (kind != null) stringResource(Res.string.unsupported_event_kind_2, kind) else stringResource(Res.string.unsupported_event_kind),
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(MaterialTheme.spacing.space175)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt
index 720ea55e..757bba37 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt
@@ -54,6 +54,7 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.be_the_first_to_comment
import mantra.composeapp.generated.resources.post
import mantra.composeapp.generated.resources.something_went_wrong
+import mantra.composeapp.generated.resources.reply_to
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -138,7 +139,7 @@ fun TextNoteEventDetail(
Text(
modifier = Modifier.weight(1f),
- text = "Reply to ${localNostrEvent.profile?.humanReadableNameOrPubkey() ?: "the above"}",
+ text = stringResource(Res.string.reply_to, localNostrEvent.profile?.humanReadableNameOrPubkey() ?: "the above"),
maxLines = 1,
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt
index 231908ea..5198ce29 100755
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt
@@ -104,6 +104,10 @@ import mantra.composeapp.generated.resources.review
import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.waiting_for_your_signature
import mantra.composeapp.generated.resources.you
+import mantra.composeapp.generated.resources.private_to
+import mantra.composeapp.generated.resources.proposals_are_waiting_for_your_signature
+import mantra.composeapp.generated.resources.reply_privately_to
+import mantra.composeapp.generated.resources.sent_a_private_message_to
class ChatMessageListViewModel(
initialChatMessageListUIState: ChatMessageListUIState,
@@ -668,7 +672,7 @@ class ChatMessageListViewModel(
)
Text(
text = if (localChatMessage.chatMessage.isUserMessage) {
- "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}"
+ stringResource(Res.string.private_to, nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey))
} else {
stringResource(Res.string.private_to_you)
},
@@ -733,7 +737,7 @@ class ChatMessageListViewModel(
) {
DropdownMenuItem(
text = {
- Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}")
+ Text(stringResource(Res.string.reply_privately_to, nameFor(localChatMessage.chatMessage.senderPublicKey)))
},
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = Decorative)
@@ -887,7 +891,7 @@ private fun ProposalsAwaitingYouNotice(
text = if (single != null) {
stringResource(Res.string.waiting_for_your_signature)
} else {
- "${proposals.size} proposals are waiting for your signature"
+ stringResource(Res.string.proposals_are_waiting_for_your_signature, proposals.size)
},
style = MaterialTheme.typography.labelMedium
)
@@ -948,7 +952,7 @@ private fun PrivateMessageNotice(
)
Text(
- text = "$sender sent a private message to $recipient",
+ text = stringResource(Res.string.sent_a_private_message_to, sender, recipient),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt
index 3cfd25e0..f9d32e02 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt
@@ -5,6 +5,9 @@ import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.currently_no_messages_have_been_shared
import mantra.composeapp.generated.resources.don_t_sign
import mantra.composeapp.generated.resources.something_went_wrong
+import mantra.composeapp.generated.resources.add_chapter_to
+import mantra.composeapp.generated.resources.chapter_words_characters
+import mantra.composeapp.generated.resources.sent_a_private_message_to
import org.jetbrains.compose.resources.getString
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -45,4 +48,31 @@ class StringCatalogueJvmTest {
fun `a plain string comes back unchanged`() = runBlocking {
assertEquals("Something went wrong", getString(Res.string.something_went_wrong))
}
+
+ @Test
+ fun `a format string substitutes its arguments in order`() = runBlocking {
+ // The interpolated strings became `%1$s` format strings, and the argument order
+ // is decided by where each `${…}` sat in the template. A transposition compiles
+ // and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so
+ // the ordering is worth asserting on a multi-argument one.
+ assertEquals(
+ "Add chapter to Genesis",
+ getString(Res.string.add_chapter_to, "Genesis"),
+ )
+ assertEquals(
+ "Ada sent a private message to Grace",
+ getString(Res.string.sent_a_private_message_to, "Ada", "Grace"),
+ )
+ }
+
+ @Test
+ fun `a unicode escape was decoded into the catalogue`() = runBlocking {
+ // `·` is Kotlin source syntax with no meaning in XML, so the extractor
+ // decodes it and the resource holds the character. If it had been left alone the
+ // app would render the six characters of the escape.
+ assertEquals(
+ "Chapter 3 · 900 words · 4800 characters",
+ getString(Res.string.chapter_words_characters, "3", "900", "4800"),
+ )
+ }
}
diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md
index fdc69f51..3fc8dda6 100644
--- a/docs/material-design-conformance.md
+++ b/docs/material-design-conformance.md
@@ -608,35 +608,51 @@ neither an outline nor a checkmark, and adding one is component work.
### Phase 4 — text the system can translate
-**Why here.** It is the last phase that touches every file, and doing it after
-Phase 2 means one pass over each file instead of two. It must precede Phase 6:
-RTL is a breakpoint concern too, and there is no point testing a mirrored layout
-against 334 English literals.
+**Why here.** It is the last phase that touches every file, and doing it after phase 2
+means one pass over each file instead of two. It must precede phase 6: RTL is a breakpoint
+concern too, and there is no point testing a mirrored layout against 334 English literals.
-**Work.**
+**Built.** Three commits.
-1. **Externalise all 334 strings** into `composeResources/values/strings.xml`,
- organised by screen. Clear out the inherited Phoenix wallet strings that
- nothing references, and settle `app_name`.
-2. **Sentence case, everywhere.** "Edit profile", "Create profile", "New chat",
- "Sign in", "Leave group", "Key package management", "Publish new key
- package". Roughly 45 strings. Product names stay capitalised — which requires
- settling on one: Torch, Mantra or Machankura.
-3. **Rewrite the destructive confirmations** to state consequences plainly.
- "Delete group" and "Leave group" currently offer a label and nothing else;
- the style guide wants the outcome and whether it can be undone.
-4. **Alt text for meaningful images** — profile avatars, QR codes, artifact
- images — following the Phase 3 triage rule.
-5. **Spell out abbreviations** in user-facing text. Protocol terms that are
- genuinely the domain (npub, NIP-05, relay) stay; incidental shortenings go.
-6. Verify RTL by mirroring: the codebase has no `left`/`right` modifiers, so
- this should be confirmation rather than repair.
+1. **Sentence case, 100 occurrences across 60 strings.** Two passes, and the second is
+ the instructive one: the first pattern required every word after the first to be
+ capitalised, so anything with an article survived — "Invite a Friend" was still on the
+ app's first screen after the audit reported zero — and it read one line at a time, so a
+ `Text(` whose literal sat on the next line was invisible.
-**Done when** the audit reports fewer than ten string literals in composables
-(test data and previews), and no user-facing string uses title case.
+ Sample data is deliberately left in title case: "Steve Biko", "To Kill a Mockingbird".
+ Those are a person and a book.
-**Risk:** low, high volume. Sentence-casing is the part most likely to draw
-disagreement — settle the product name first, in one decision.
+2. **251 plain strings into the catalogue**, 315 call sites. The extractor took four
+ attempts and each failure is recorded in `docs/scripts/m3-extract-strings.py`: a bare
+ `text = "…"` is not a Compose string (it rewrote a data class), a regex over quote
+ pairs is not a Kotlin lexer (it lifted `"chunk"` out of `"${if (n == 1) "chunk" …}"`),
+ and a fragment of a `+` concatenation is not a translatable unit.
+
+3. **40 interpolated strings as format strings**, 49 call sites — `${expr}` to `%1$s`,
+ passed as arguments.
+
+**Compose Resources is not aapt, and a test caught that before a device did.** The first
+extraction escaped apostrophes as `\'` and doubled `%`, which is what android's resource
+compiler requires. Compose Resources does neither: `getString` returned `Don\'t sign`,
+backslash included, across 30-odd strings. Escape handling is *partial* rather than
+absent — `\n` **is** processed — so there is no family rule to lean on.
+`StringCatalogueJvmTest` asserts each escape it depends on.
+
+**Done:** literals in composables 334 → 39, `stringResource` 0 → 424, title case 0. Plus
+1101 inherited phoenix strings deleted (nothing referenced them), the product settled on
+one name, and the two destructive actions now state their consequences — read out of the
+repository rather than guessed, because "Delete group" with no qualifier invites the
+belief that the messages are gone from the relays, which is the opposite of true.
+
+**Left for later, and why it is not a script's job.** 83 literals that are terms of a `+`
+concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what
+the whole sentence is, and several of them are pluralisations —
+`(if (n == 2) "event" else "events")` — which want a real plural resource rather than a
+format argument. `m3-extract-formatted.py --remaining` lists them.
+
+`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is
+a network identity question rather than a content one.
---
diff --git a/docs/scripts/m3-extract-formatted.py b/docs/scripts/m3-extract-formatted.py
new file mode 100755
index 00000000..bec26bbc
--- /dev/null
+++ b/docs/scripts/m3-extract-formatted.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""Move interpolated UI strings into the catalogue as format strings.
+
+Companion to m3-extract-strings.py, which handles literals with no interpolation. Here a
+
+ Text("Add chapter to ${uiState.artifact.name}")
+
+becomes a resource holding `Add chapter to %1$s` and a call
+
+ Text(stringResource(Res.string.add_chapter_to, uiState.artifact.name))
+
+**Only standalone literals.** A literal that is one term of a `+` concatenation is left
+alone: reassembling `"a " + x + " b"` into a single format string means deciding what the
+whole sentence is, and that is per-site reading rather than a rewrite. Those are listed by
+--remaining.
+
+Usage:
+ m3-extract-formatted.py dry run
+ m3-extract-formatted.py --apply
+ m3-extract-formatted.py --remaining list what is deliberately left
+"""
+import os, re, sys, io, collections
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+_helpers = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ 'm3-extract-strings.py')).read()
+exec(_helpers.split('def resource_name(')[0]) # UI, STRINGS, kotlin_strings, ...
+exec('def resource_name(' + _helpers.split('def resource_name(')[1].split('def xml_escape')[0])
+
+DRY = '--apply' not in sys.argv
+
+
+def split_template(body):
+ """Kotlin template body -> (segments, expressions).
+
+ segments is the literal text with each interpolation replaced by a placeholder index.
+ """
+ parts, exprs, i, buf = [], [], 0, ''
+ while i < len(body):
+ c = body[i]
+ if c == '\\':
+ buf += body[i:i + 2]
+ i += 2
+ continue
+ if c == '$' and i + 1 < len(body):
+ if body[i + 1] == '{':
+ depth, j = 1, i + 2
+ while j < len(body) and depth:
+ if body[j] == '{':
+ depth += 1
+ elif body[j] == '}':
+ depth -= 1
+ j += 1
+ parts.append(buf)
+ buf = ''
+ exprs.append(body[i + 2:j - 1])
+ i = j
+ continue
+ m = re.match(r'\$([A-Za-z_][A-Za-z0-9_]*)', body[i:])
+ if m:
+ parts.append(buf)
+ buf = ''
+ exprs.append(m.group(1))
+ i += m.end()
+ continue
+ buf += c
+ i += 1
+ parts.append(buf)
+ return parts, exprs
+
+
+def unescape_for_xml(text):
+ """Kotlin escapes -> what belongs in the resource file.
+
+ `\\uXXXX` and `\\"` are Kotlin source syntax with no meaning in XML, so they are
+ decoded: a resource file is UTF-8 and can hold the character itself. `\\n` is left
+ alone, because StringCatalogueJvmTest shows Compose Resources processes it and a
+ real newline in an XML value would be reflowed by the parser.
+ """
+ out, i = '', 0
+ while i < len(text):
+ if text[i] == '\\' and i + 1 < len(text):
+ nxt = text[i + 1]
+ if nxt == 'u' and i + 6 <= len(text):
+ out += chr(int(text[i + 2:i + 6], 16))
+ i += 6
+ continue
+ if nxt in '"\'':
+ out += nxt
+ i += 2
+ continue
+ out += text[i]
+ i += 1
+ return out
+
+
+def format_string(parts, exprs):
+ out = unescape_for_xml(parts[0]).replace('%', '%%')
+ for n, tail in enumerate(parts[1:], start=1):
+ out += f'%{n}$s' + unescape_for_xml(tail).replace('%', '%%')
+ return out
+
+
+def candidates(source):
+ """(start, end, body, exprs, format_string) for standalone interpolated literals."""
+ spans = text_call_spans(source)
+ found = []
+ for start, end, body, interpolated in kotlin_strings(source):
+ if not interpolated or not body.strip():
+ continue
+ if not any(a <= start <= b for a, b in spans):
+ continue
+ line_start = source.rfind('\n', 0, start) + 1
+ line = source[line_start:source.find('\n', start)]
+ if line.lstrip().startswith(('//', '*', '/*')):
+ continue
+ before, after = source[:start].rstrip(), source[end:].lstrip()
+ stripped = line.strip()
+ if before.endswith('+') or after.startswith('+') or \
+ stripped.endswith('+') or stripped.startswith('+'):
+ continue
+ parts, exprs = split_template(body)
+ if not exprs:
+ continue
+ # A template that is *only* interpolation has nothing to translate.
+ # `Text("$name")` would become `stringResource(Res.string.string, name)`, which is
+ # a resource holding "%1$s" -- longer, slower and no more localisable than the
+ # code it replaced. Same for one whose literal half is punctuation or a spacer.
+ literal = unescape_for_xml(''.join(parts))
+ if not any(c.isalpha() for c in literal):
+ continue
+ fmt = format_string(parts, exprs)
+ # A leading or trailing space means the string is being glued to a neighbour --
+ # " \u00b7 %1$s" is a separator, not a sentence. The test is on the *format
+ # string*, not on the literal halves: a template that opens with an interpolation
+ # leaves parts[0] empty and parts[1] starting with the space that separates them,
+ # so joining the halves makes "%1$s Key packages" look like a fragment when it is
+ # a whole label.
+ if fmt != fmt.strip():
+ continue
+ found.append((start, end, body, exprs, fmt))
+ return found
+
+
+
+def has_import(source, statement):
+ """Whole-line match.
+
+ `statement in source` is wrong here and silently so: the accessors are named after
+ their strings, so `...resources.translate` is a prefix of
+ `...resources.translate_into_which_dialect`, and a substring test decides the import
+ is already present. The compiler then reports "Unresolved reference 'translate'" in a
+ file whose imports look complete.
+ """
+ return any(line.strip() == statement for line in source.split('\n'))
+
+def main():
+ if '--remaining' in sys.argv:
+ for root, _, files in os.walk(UI):
+ for f in sorted(files):
+ if not f.endswith('.kt'):
+ continue
+ path = os.path.join(root, f)
+ src = open(path, encoding='utf-8').read()
+ spans = text_call_spans(src)
+ done = {(a, b) for a, b, *_ in candidates(src)}
+ for a, b, body, _i in kotlin_strings(src):
+ if not body.strip() or (a, b) in done:
+ continue
+ if not any(x <= a <= y for x, y in spans):
+ continue
+ line_start = src.rfind('\n', 0, a) + 1
+ line = src[line_start:src.find('\n', a)]
+ if line.lstrip().startswith(('//', '*', '/*')):
+ continue
+ print(f' {path.replace(UI + "/", "")}:{src[:a].count(chr(10)) + 1} {body[:70]!r}')
+ return 0
+
+ plan = {}
+ for root, _, files in os.walk(UI):
+ for f in sorted(files):
+ if not f.endswith('.kt'):
+ continue
+ path = os.path.join(root, f)
+ for _s, _e, body, exprs, fmt in candidates(open(path, encoding='utf-8').read()):
+ plan[fmt] = plan.get(fmt, 0) + 1
+
+ existing = io.open(STRINGS, encoding='utf-8').read()
+ taken = set(re.findall(r''
+ f'{fmt.replace("&", "&").replace("<", "<").replace(">", ">")}'
+ for fmt in sorted(plan, key=lambda x: names[x]))
+ io.open(STRINGS, 'w', encoding='utf-8').write(
+ existing.replace('', entries + '\n'))
+ print(f'{len(plan)} entries written')
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/docs/scripts/m3-extract-strings.py b/docs/scripts/m3-extract-strings.py
index 5a399375..e4cbd4e5 100755
--- a/docs/scripts/m3-extract-strings.py
+++ b/docs/scripts/m3-extract-strings.py
@@ -193,6 +193,18 @@ def xml_escape(text):
return text.replace('&', '&').replace('<', '<').replace('>', '>')
+
+def has_import(source, statement):
+ """Whole-line match.
+
+ `statement in source` is wrong here and silently so: the accessors are named after
+ their strings, so `...resources.translate` is a prefix of
+ `...resources.translate_into_which_dialect`, and a substring test decides the import
+ is already present. The compiler then reports "Unresolved reference 'translate'" in a
+ file whose imports look complete.
+ """
+ return any(line.strip() == statement for line in source.split('\n'))
+
def main():
occurrences = collections.defaultdict(list)
for root, _, files in os.walk(UI):
@@ -233,7 +245,7 @@ def main():
if changed:
for imp in ('import mantra.composeapp.generated.resources.Res',
'import org.jetbrains.compose.resources.stringResource'):
- if imp not in src:
+ if not has_import(src, imp):
lines = src.split('\n')
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
lines.insert(idx + 1, imp)
@@ -245,7 +257,7 @@ def main():
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
for nm in reversed(need):
imp = f'import mantra.composeapp.generated.resources.{nm}'
- if imp not in src:
+ if not has_import(src, imp):
lines.insert(idx + 1, imp)
src = '\n'.join(lines)
io.open(path, 'w', encoding='utf-8').write(src)