Skip to main content
In this tutorial you localize Foyer, and the first thing you localize is not a view. Five messages in the shared core are English sentences today: the two refusals of a display name, the two refusals of a sign-in, and a declined purchase. They sit in state, they sit in six recordings, and a translator cannot reach them. You turn each into a value the reducer writes and the view names, re-record the features that carry them, and then move every string the two apps show into each platform’s own localization system: one string catalog per shell target on iOS, with accessors generated by the xcstrings-tool plugin at build time, and string resources on Android. Then you add German on both and take the receipt: after the migration, a translation changes no recording. This is the eighth page of the nine-tutorial series, and it starts from Tutorial 6’s finished tree, as Tutorials 7 and 9 do.
The manual setup below is what Modaal automates. Every tree this tutorial opens and every file it asks you to write is produced by the Duet templates in the Modaal new-project wizard, and a coding agent working in Modaal writes the feature, records it and runs the checks for you. This series walks the same ground by hand so you know what the scaffold emits and why: start a Duet project in the wizard when you would rather skip the setup.

What will you build?

A localized app on both platforms, with the rule that makes it cheap: the shared logic carries values, the views carry strings, and nothing in between carries either. The reducers already say Free, Monthly, Guest, digest, and after this tutorial they also say NameValidation.Empty, SignInFailure.NoAccount and PurchaseFailure.Declined where they said “Enter a name.”, “No account for that address.” and “Payment declined.” The views say every sentence, through a catalog or a resource. Two view shells that projected strings into their view state stop doing so, and the tests that compared those strings compare values instead. Expect about two and a half hours: the core change is small, the string moves are many. You will have at the end:
  • Three sealed values in the core, NameValidation, SignInFailure and PurchaseFailure, in place of five English sentences, with six recordings re-recorded to carry a case where they carried a string.
  • Nine Localizable.xcstrings catalogs, one per shell target, with the xcstrings-tool plugin generating a String.Localizable accessor per key on every build; values/strings.xml on Android with sixty-five strings and one plural.
  • German on both platforms, with a test on each that holds the second language to the first: every key translated, every placeholder matched.
  • The two apps running in German from one command each, refusals included, and a test in the root module that fails if a recording ever carries a string the resources own.

Where do you start?

Open tutorial8-start from the duet-tutorials repository. It is Tutorial 6’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1 with Tutorial 6’s toolchain. Tutorials 7, 8 and 9 all open this same tree. Run the checks once before you edit anything:
The Kotlin lane reports one failure, Tutorial8ExerciseRecordingsUnchangedTest in the root module; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.

The steps

1

Find every string

Before a catalog exists, count what goes into it. The SwiftUI views under src-ios/Libraries/FoyerKit/Sources hold forty string literals and the Compose screens under src-kmp/app hold thirty-eight: titles, buttons, placeholders, the tab labels, three accessibility labels, the promo, the plan cards, the insights rows. Then look at what the core says. The entitlement is Free or Premium(plan), the plan is Monthly or Yearly, the provider is Email or Guest, a preference is its key, digest; the price is a string the purchases port formats. None of those is a sentence. Five things the core says are sentences:
The editor’s validator returns “Enter a name.” or “Keep it to 40 characters or fewer.”; the sign-in gate writes “Enter an email address.” into state and the auth port answers “No account for that address.”; the purchases port answers “Payment declined.” Each ends in a state field, validation or failure, that a view prints as it is, and each is in a recording: name.empty-name-rejected, both editname.*-rejected fixtures, signin.empty-address-fails, signin.port-failure-lands and upgrade.purchase-failure-lands carry the English sentence in their expected state. A German user gets those five in English, and a translator who fixes the wording changes a recording. That is what step 2 removes. Step 3 then moves the views’ forty and thirty-eight, and step 4 the last three strings that live between the core and the views: the profile shell projects planLabel, the preferences shell projects a label per toggle, and the upgrade view compares one string to another to choose the billing line:
In German the plan’s name is “Monatlich” and that comparison is wrong on every card.
2

Make what the logic says a value

A refusal is a fact the reducer knows: the draft was empty, the address has no account, the payment was declined. The sentence that tells the user is the view’s, in the user’s language. So each message becomes a sealed value, coded like the ports’ other sum types, with one case per reason. The name validator lives in the editor’s module, where Tutorial 5 put it so the editor and the onboarding name step share one rule, and it moves first:
src-kmp/subtrees/editname/logic/src/commonMain/kotlin/dev/modaal/foyer/editname/EditNameFeature.kt
The two refusals of a sign-in and the one refusal of a purchase go into the ports module, because the port’s answer carries them: SignInOutcome.Failed(reason: SignInFailure) and PurchaseOutcome.Failed(reason: PurchaseFailure).
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt
Each gets a CanonicalSumSerializer beside the others, and the state fields change type: validation: NameValidation? on the name step and the editor, failure: SignInFailure? on the gate, failure: PurchaseFailure? on the flow. The reducers do not change. The name step still writes whatever the validator returned, the gate still writes SignInFailure.EmptyAddress where it wrote the constant, and both still write the port’s reason through:
src-kmp/subtrees/name/logic/src/commonMain/kotlin/dev/modaal/foyer/name/NameFeature.kt
The on-device backend answers with a case instead of a sentence, and the scenarios compare cases:
src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInScenarioTest.kt
Now re-record the four features. This is the one point in the tutorial where a recording changes, and the diff says exactly what moved:
Six recordings change like this, a string replaced by a case; seven more of the same features change only in the scenario line numbers their steps cite, which tools/duet record --check treats as metadata. Nothing else on the tree moved: no action, no effect, no other field. The feature specs under parity/feature-specs name the new types in their state tables, and tools/duet verify is green again.Two things now hold. A translator can change every one of those five sentences without touching the core, because the core no longer has them. And the shells on both platforms receive a case and must name it, which is the next two steps.
4

Move the shells' strings into the views

A view shell projects state into view state; a string in view state is a string the shell chose, in a language the shell does not know, and a string the shell’s tests then compare. The three cases from step 1 each become a value. The upgrade shell’s confirm step carried the plan’s name; it carries a key now, and the view names the plan and the billing line from it, so nothing compares two strings:
src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeViewShell.swift
src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeView.swift
src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeView.swift
The profile shell’s planLabel becomes plan, a three-case value, with the same mapping at the end of the view file; the preferences shell’s row keeps the key and drops the label, and the view names the toggle from the key:
src-ios/Libraries/FoyerKit/Sources/ProfileShell/ProfileViewShell.swift
src-ios/Libraries/FoyerKit/Sources/OnboardingShell/StepShells.swift
src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingView.swift
Four assertions in the shell specs compared the projected strings; they compare the values now, in MainViewShellSpec and RootCompositionSpec:
src-ios/Libraries/FoyerKit/Tests/MainShellTests/MainViewShellSpec.swift
That is the whole Swift side: no shell resolves a string, no test compares one, and swift test on the package runs its thirty-five shell specs as before. The reason the shells must not resolve strings is not only tidiness; a Common question below has the build fact behind it.
5

Write the resources

Android has had this system since its first release: res/values/strings.xml is the source language, the resource compiler generates R.string and R.plurals from it, and a Compose screen reads a resource with stringResource. The one file the app already had held the launcher label; it holds every string now, and the screens read it by name:
src-kmp/app/src/main/res/values/strings.xml
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingScreen.kt
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingScreen.kt
Placeholders are positional, %1$d and %2$d, so a translation can reorder them; the count on the insights screen is a <plurals> resource read with pluralStringResource, which takes the number twice, once to pick the form and once to print:
src-kmp/app/src/main/res/values/strings.xml
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/HomeScreen.kt
The refusals get their names the same way, a resource id per case, and the text field prints the resource:
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/EditNameScreen.kt
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/EditNameScreen.kt
The Compose side has the same three helpers the Swift side had, and they change the same way: the plan’s name and its billing line become resource ids chosen from the Plan value, and the toggle’s label becomes a composable over its key:
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/UpgradeSheet.kt
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/UpgradeSheet.kt
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingScreen.kt
6

Add the second language and hold it complete

German is one more string unit per catalog entry on iOS, de beside en, as the entries above already show, and one more directory on Android, values-de/, with the same names:
src-kmp/app/src/main/res/values-de/strings.xml
Each system also needs to be told the language exists. iOS picks an app’s language from the app bundle’s declared localizations, not from what the package’s resource bundles happen to contain, so the app’s Info.plist lists both; Android 13 and later show a per-app language row in Settings for the languages a locale-config names:
src-ios/App/xcodegen.yml
src-kmp/app/src/main/res/xml/locales_config.xml
src-kmp/app/src/main/AndroidManifest.xml
Neither platform fails a build over a missing translation: the source language fills in, and the gap shows up on a device in the second language. So each side gets a test that holds the second language to the first. The Swift one reads all nine catalogs through #filePath and checks that every key has a German unit marked translated, for every plural category of the source, with the same format specifiers; the Kotlin one parses the two resource files and checks the names and the positional placeholders:
src-ios/Libraries/FoyerKit/Tests/RootShellTests/SecondLanguageSpec.swift
src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/TranslationsCompleteTest.kt
Both join the checks where they are: the Swift one runs in the shells lane with the other specs, the Kotlin one in the Android job’s unit tests.
7

Run both apps in German

Neither app has a language picker, because both systems have one. On a device it is Settings, then the app, then Language, on both platforms. From the terminal, a simulator app takes its language as launch arguments, and an emulator app takes a per-app locale from the locale manager:
Launch each app fresh and continue as a guest. The onboarding gate opens on the welcome step:
Two phone screens side by side showing the same onboarding welcome step in German: the progress line reads Schritt 1 von 3 above three step markers, the heading Willkommen bei Foyer, a line of body text, and a full-width button labelled Weiter.

The welcome step in German, iPhone 17 simulator on the left and Pixel 8 emulator, API 36, on the right; the progress line is a format string with two integer arguments on both, the body copy one entry per catalog and per resource file; tutorial8-complete at Duet 0.7.0, duet-tools 0.24.0.

Every screen behind it is German too, the plan cards read “Monatlich” and “Jährlich”, the confirm step’s billing line follows the plan and not the name, and the insights screen says “4 Tage”. Then go back to the gate and press the email button with the field empty. The refusal is the sign-in reducer’s SignInFailure.EmptyAddress, the same case in the same recording on both platforms, and each view says it in German:
Two phone screens side by side showing the sign-in screen in German with an empty email field, the two sign-in buttons, and a red line of text under them reading Gib eine E-Mail-Adresse ein.

The sign-in gate's refusal of an empty address, in German, on the iPhone 17 simulator and the Pixel 8 emulator; the reducer wrote emptyAddress, the recording signin.empty-address-fails carries that case, and each view named it from its own catalog or resource file.

8

Take the receipt

Compare the tree against tutorial6-complete. Under src-kmp, the core changed in step 2 and nowhere else: three sealed types, four state fields, one backend line, and the scenarios that compare cases. Under parity/fixtures, thirteen files differ, six of them by the case that replaced a sentence and seven by scenario line numbers only. Everything after step 2 is under src-ios/Libraries/FoyerKit/Sources, src-ios/App and src-kmp/app, plus the tests. Adding German in step 6 changed nothing under parity:
One check does go red on the way: tools/duet mocks --check, because the mock generator fingerprints every source file in a shell’s directory, and every shell’s directory changed. tools/duet mocks rewrites the fingerprints; the generated bodies are as they were, since no shell’s interface changed. Then tools/duet verify is green, record --check is green, and the tree’s own workflow runs the two translation tests in the lanes they already run.

What you now have

  • A core that says why and never how: NameValidation, SignInFailure and PurchaseFailure where five English sentences were, and six recordings that carry the case.
  • Every string the apps show in a string catalog or a resource file, read by name from the views, with the accessors generated on each platform by that platform’s own tooling.
  • No view shell that resolves a string and no test that compares one; the three places that did carry values now.
  • German on both platforms, refusals included, held complete by a test on each side, and both apps running in it from one command.

Exercise: pin the receipt

tutorial8-start carries Tutorial8ExerciseRecordingsUnchangedTest, a failing placeholder in the root module. Step 2 found the five sentences by reading; make the checks find the next one. After step 5 the resources hold every sentence the app shows, and the recordings hold every value the reducers write, so the two sets must be disjoint: no string in any recording’s state, action or effect may equal a resource’s value. Delete the stub and write the test in its place:
src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RecordingsHoldNoDisplayTextTest.kt
tutorial8-complete carries the test. To see it work, copy tutorial6-complete’s name.empty-name-rejected.fixture.json over the tree’s and run tools/duet verify: the test names the fixture and “Enter a name.”, which is now a resource value. Restore the fixture. A reducer that writes a sentence into state after this fails the same way on its next recording, and its own scenario stays green, because a scenario cannot tell copy from a value.

Common questions

The plugin generates one accessor type per target, and the type is internal to it, so a catalog beside its views is the shape that needs no configuration. One catalog for the whole package would be a Strings target with an xcstrings-tool-config.yaml setting accessLevel: public, a dependency from every shell, and one file that every feature edits. Nine files that each change with their own views is the same shape the shells already have for their mocks and their tests.
Two reasons, and the second is a build fact. A shell’s tests compare its view state, so a projected string is a string the tests pin in one language. And the package’s swift test runs on macOS through Swift Package Manager, which copies Localizable.xcstrings into the resource bundle as it is; only Xcode compiles a catalog into the tables the runtime reads. On that lane a shell that resolved .planFree would see the key, not “Free”, and the spec would fail. Views are never rendered in the shells lane and the app is built by Xcode, so a string resolved in a view is always resolved against a compiled catalog.
Because a message is a language, and a language in state is a language in every recording that carries it. With “Enter a name.” in state, the German user reads English on the name step, a translator who improves the wording re-records three features, and the same sentence lives in the core and in nothing a translator’s tools can see. With NameValidation.Empty in state, the reducer says why, the recording pins why, and each platform’s view says it in the user’s language from the same catalog or resource file as every other sentence. The port’s reason is the same case: a backend that returns error codes maps them to SignInFailure at the port, and a backend that returns sentences has no place to put them.
A guest session seeds displayName with “Guest”, the same value on both platforms, and it is in four recordings. It stays: it is the account’s saved name until the user sets one, a stored value the account port owns, not a sentence the view chooses, and the name step replaces it before the main screen shows. A name the app invents for a user is data with a default, and the default is the port’s.
Because the core would then carry a language, and every recording of a feature that shows text would carry it too; a changed translation would be a changed fixture, and a translator’s edit would need a re-record. Kept in the shells, the strings live in the two systems the platforms’ own tools, editors and translators already handle, string catalogs and string resources, and the core stays the one place that never has to know what language the user reads.
By the system, on both platforms: the device language, or since iOS 13 and Android 13 a per-app language the user picks in Settings. iOS offers that row for every language in CFBundleLocalizations; Android offers it for every language the manifest’s localeConfig names, and the set-app-locales command from step 6 sets the same preference. An app that wants its own picker sets AppleLanguages in its defaults on iOS and calls AppCompatDelegate.setApplicationLocales on Android; that is a preference the shell stores, not feature state.
Both platforms resolve plural forms from the language’s rules at run time, given a resource with one string per category; step 2 and step 4 each have one. German uses the same two categories as English, so the completeness tests, which compare the source’s categories to the translation’s, pass without special cases; a language with more categories adds them to its own entry. Word order is why placeholders are positional: “Confirm %@” is ”%@ bestätigen” in the German catalog, and “Confirm %1s"is"s" is "%1s bestätigen” in the German resources, with the argument list unchanged at the call site.

Sources and further reading

Tutorial 6: The Checks in CI

The tree this page opens, and the workflow the two translation tests run in.

Tutorial 7: Theming with Design Tokens

The same tree, the same rule for colors: the state says locked, the view picks the token.

Tutorial 9: Adding Analytics

The same tree, seven events emitted by the reducers as effect data, a console sink worker on each platform, and the recordings checking every event.

The Duet tutorial series

The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
Last modified on September 8, 2026