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 sayFree, 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,SignInFailureandPurchaseFailure, in place of five English sentences, with six recordings re-recorded to carry a case where they carried a string. - Nine
Localizable.xcstringscatalogs, one per shell target, with the xcstrings-tool plugin generating aString.Localizableaccessor per key on every build;values/strings.xmlon 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?
Opentutorial8-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:
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
Find every string
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: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:Make what the logic says a value
SignInOutcome.Failed(reason: SignInFailure) and PurchaseOutcome.Failed(reason: PurchaseFailure).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: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.Write the catalogs and link the generator
Localizable.xcstrings, that Xcode edits as a table and the build compiles into the resource bundle. Each shell target gets its own, beside its views, and the xcstrings-tool plugin turns every key into a Swift accessor at build time. The package manifest declares the source language, adds the plugin as its second dependency, pinned exactly like the first, and gives every shell target its catalog as a resource and the plugin as a build tool:defaultLocalization: "en" at the top. A catalog entry is a key, a state, and one string unit per language; the key is lowerCamelCase because it becomes a Swift identifier. This is the onboarding shell’s progress line, with the German it gets in step 5 already in place, and %lld where an integer goes:.build/plugins/outputs, never into the tree. The first build on a machine asks you to trust the plugin, in Xcode’s dialog or in swift build’s prompt; a CI runner has no one to answer, so the workflow’s xcodebuild line passes -skipPackagePluginValidation, and scripts/run-tree.sh does the same. For that entry it generates a function, with one Int parameter per specifier; a key without specifiers becomes a static property:Text(localizable:) for SwiftUI, which resolves the string through the target’s own resource bundle, and String(localized: .localizable(…)) for an API that wants a String: a text field’s prompt, a button’s title, an accessibility label. The generated type is internal to its target, which is why the catalog lives beside the views that read it. The progress row and the welcome step now read:Text(localizable: validation.message); the onboarding name step has the same extension over the same type, the sign-in view one over SignInFailure, the upgrade view one over PurchaseFailure. Three shell specs that compared the message compare the case now, XCTAssertTrue(child.shell.viewState.validation is NameValidationEmpty). One string on the insights screen is a count, “4 days”, and a count is a plural: the catalog entry carries a one and an other variation instead of one unit, and the accessor takes the number:Output written to line per catalog, nine in all, and compiles without a string literal left in a view.Move the shells' strings into the views
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:MainViewShellSpec and RootCompositionSpec: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.Write the resources
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:%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:Plan value, and the toggle’s label becomes a composable over its key:Add the second language and hold it complete
de beside en, as the entries above already show, and one more directory on Android, values-de/, with the same names:Info.plist lists both; Android 13 and later show a per-app language row in Settings for the languages a locale-config names:#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:Run both apps in German

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.
SignInFailure.EmptyAddress, the same case in the same recording on both platforms, and each view says it in German:
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.
Take the receipt
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: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,SignInFailureandPurchaseFailurewhere 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:
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
Why one catalog per shell target, and not one for the app?
Why one catalog per shell target, and not one for the app?
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.Why must a view shell not resolve a string?
Why must a view shell not resolve a string?
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.Why is a refusal a value and not a message?
Why is a refusal a value and not a message?
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.What about the guest's name?
What about the guest's name?
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.How is the language chosen?
How is the language chosen?
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.What about plurals, and word order?
What about plurals, and word order?
Sources and further reading
- xcstrings-tool and its Swift package plugin — the generator, its configuration file, and the generated API.
- Apple: Localizing and varying text with a string catalog — the catalog format, plural variations, and the editor;
LocalizedStringResource— the type the accessors resolve through. - Android: Localize your app — resource directories per language; Per-app language preferences —
localeConfigand the Settings row; Quantity strings — the<plurals>resource. - The Duet glossary — shell, golden recording and the checks.