refactor: make the 40 interpolated UI strings format strings, and assert the argument order

Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to
${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call
passing the expression. 49 call sites. Literals in composables go 76 -> 39;
`stringResource` goes 374 -> 424.

**A silent bug in the previous commit's extractor, found by this one.** Imports were
tested with `statement in source`, and the generated accessors are named after their
strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of
`...resources.translate_into_which_dialect`. The substring test decided the import was
already there, and the compiler reported "Unresolved reference 'translate'" in a file
whose imports looked complete. Both extractors now match whole lines, and the helper
carries the explanation.

**Four filters, each earned by something the dry run got wrong.**

*A template that is only interpolation has nothing to translate.* `Text("$name")` would
have become a resource holding `%1$s` -- longer, slower, and no more localisable than the
code it replaced.

*A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a
separator. The test has to be on the format string rather than on the literal halves: a
template opening with an interpolation leaves the first part empty and the second starting
with the separating space, which makes "%1$s Key packages" look like a fragment when it is
a whole label.

*`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the
six visible characters of the escape. They are decoded into the resource, which is UTF-8
and can hold `·` directly. `\\n` is **not** decoded, because
StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an
XML value would be reflowed by the parser.

*A term of a `+` concatenation is still not a string.* Same rule as the plain extractor.

**Three copy problems surfaced only here, because interpolated strings had never been
checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a
literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had
`"replying To ${…}"`. And a third instance of the old product name, in
`"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather
than a one-off: title case inside a template is still unchecked, and there are 83
concatenation fragments left where it could hide.

**Two new assertions, on the two things a compiler cannot see.** Argument *order* is
decided by where each `${…}` sat, and a transposition compiles and reads plausibly --
"Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument
string are asserted end to end. The three-argument one doubles as the check that `·`
was decoded rather than passed through.

**What is deliberately left.** 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 are pluralisations -- `(if (n == 2) "event" else "events")` --
which want a real plural resource rather than a format argument, and that is an API choice
rather than a rewrite. `m3-extract-formatted.py --remaining` lists them.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349.
`:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on
emulator-5554 through onboarding, the message list and a chat room with its text intact.
`m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 01:45:35 +02:00
parent 419504c982
commit 2117e22d48
26 changed files with 462 additions and 76 deletions

View File

@@ -272,4 +272,45 @@
<string name="you_will_be_in_full_control_of_this_profile">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.</string>
<string name="your_profile_is_almost_ready_just_getting_it">Your profile is almost ready... just getting it's first cryptographic signature together.</string>
<string name="your_share_of_it_is_on_this_device_only_your">Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.</string>
<string name="add_chapter_to">Add chapter to %1$s</string>
<string name="add_people_to">Add people to %1$s</string>
<string name="chapter">Chapter %1$s</string>
<string name="chapter_words_characters">Chapter %1$s · %2$s words · %3$s characters</string>
<string name="chunk">Chunk %1$s</string>
<string name="chunks">Chunks (%1$s)</string>
<string name="chunks_translated">%1$s/%2$s chunks translated</string>
<string name="event_s_still_unreadable">%1$s event(s) still unreadable%2$s</string>
<string name="events_signed_together">%1$s events, signed together</string>
<string name="everyone_has_to_be_online_at_the_same_time">Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part.</string>
<string name="functionality_coming_soon_2">"%1$s" functionality coming soon</string>
<string name="how_should_be_run">How should %1$s be run?</string>
<string name="invite_2">Invite %1$s</string>
<string name="key_packages">%1$s Key Packages</string>
<string name="next_with">Next with %1$s</string>
<string name="nostr">nostr:%1$s..</string>
<string name="nostr_2">nostr:%1$s...</string>
<string name="of">%1$s of %2$s</string>
<string name="of_members_will_be_needed_to_sign_with_this">%1$s of %2$s members will be needed to sign with this key.</string>
<string name="of_them_could_not_be_read">%1$s of them could not be read</string>
<string name="once_invited_will_be_able_to_receive_and">Once invited will be able to receive and send private message sent to all the %1$s members in the chat room.</string>
<string name="private_message_to">Private message to %1$s</string>
<string name="private_to">Private to %1$s</string>
<string name="proposals_are_waiting_for_your_signature">%1$s proposals are waiting for your signature</string>
<string name="recovered_of_event_s">Recovered %1$s of %2$s event(s)%3$s</string>
<string name="recovered_of_still_unreadable">Recovered %1$s of %2$s · %3$s still unreadable%4$s</string>
<string name="reply_privately_to">Reply privately to %1$s</string>
<string name="reply_to">Reply to %1$s</string>
<string name="replying_to">replying To %1$s</string>
<string name="searching_for_on">Searching for %1$s on "%2$s"</string>
<string name="selected">%1$s selected</string>
<string name="sent_a_private_message_to">%1$s sent a private message to %2$s</string>
<string name="shared_key_for">Shared key for %1$s</string>
<string name="to_join_the_chat_room">to join the %1$s chat room.</string>
<string name="translate">Translate %1$s</string>
<string name="unsupported_event_kind_2">Unsupported event kind: %1$s</string>
<string name="was_created_but_couldn_t_be_added_yet_invite">%1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Torch.</string>
<string name="was_created_but_its_shared_key_ceremony">%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.</string>
<string name="words_characters">%1$s words · %2$s characters</string>
<string name="you_and_others">You and %1$s others</string>
</resources>

View File

@@ -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(

View File

@@ -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
)

View File

@@ -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(

View File

@@ -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))
}
)
}

View File

@@ -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))
}
)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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<Event>, 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
)
}

View File

@@ -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),
)
}

View File

@@ -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)
)
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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))
}
)
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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
)

View File

@@ -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"),
)
}
}

View File

@@ -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.
---

View File

@@ -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'<string name="([^"]+)"', existing))
names = {}
for fmt in sorted(plan):
# name from the literal half only -- "%1$s" contributes nothing readable
names[fmt] = resource_name(re.sub(r'%\d+\$s', ' ', fmt), taken)
taken.add(names[fmt])
print(f'{len(plan)} distinct format strings, {sum(plan.values())} occurrences')
if DRY:
for fmt in sorted(plan)[:12]:
print(f' {names[fmt]:44s} {fmt[:64]!r}')
return 0
rewritten = 0
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 = io.open(path, encoding='utf-8').read()
found = candidates(src)
if not found:
continue
for start, end, _body, exprs, fmt in reversed(found):
args = ', '.join(exprs)
src = src[:start] + f'stringResource(Res.string.{names[fmt]}, {args})' + src[end:]
rewritten += 1
need = sorted({names[fmt] for *_x, fmt in [(c[0], c[4]) for c in found]})
for imp in (['import mantra.composeapp.generated.resources.Res',
'import org.jetbrains.compose.resources.stringResource']
+ [f'import mantra.composeapp.generated.resources.{n}' for n in need]):
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)
src = '\n'.join(lines)
io.open(path, 'w', encoding='utf-8').write(src)
print(f'{rewritten} call sites rewritten')
entries = '\n'.join(
f' <string name="{names[fmt]}">'
f'{fmt.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")}</string>'
for fmt in sorted(plan, key=lambda x: names[x]))
io.open(STRINGS, 'w', encoding='utf-8').write(
existing.replace('</resources>', entries + '\n</resources>'))
print(f'{len(plan)} entries written')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -193,6 +193,18 @@ def xml_escape(text):
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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)