feat: localisation for Dynamic Widgets and Dynamic Live Activities - #332
Merged
Merged
Conversation
…tivities Records what Voltra covers today for localising Dynamic Widgets and Dynamic Live Activities on iOS and Android, the gaps found (non-BCP-47 env.locale on iOS, extension-bundle language selection, no Android re-render on locale change, Hermes Intl subset, missing formatting preferences, English-only Edit Widget sheet), and proposes on-demand JS rendering with a complete locale environment as the single source of truth, with app.json locale maps feeding the two system-rendered surfaces. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Extend WidgetEnvironment (and the LiveActivityEnvironment pick) with the locale fields ADR 0008 defines: preferredLanguages, appLocale, layoutDirection, hourCycle, timeZone, measurementSystem, calendar and firstDayOfWeek, and make locale a required BCP-47 tag. Move pickLocalizedValue into @use-voltra/core (new ./locale subpath) and add resolveLocale(env, supported), which negotiates appLocale, then preferredLanguages, then locale with the initial-state fallback order. @use-voltra/expo-plugin re-exports the old names, and the iOS/Android component packages re-export the helpers for widget code. Prebuild placeholder envs carry the new required fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
…ment Dynamic Widgets and Dynamic Live Activities received Locale.identifier (`pl_PL`), which Intl rejects with a RangeError. Both env builders now go through a shared, Foundation-only VoltraLocaleEnvironment that emits identifier(.bcp47) plus preferredLanguages, layoutDirection (from the SwiftUI environment), hourCycle, timeZone, measurementSystem, calendar, firstDayOfWeek and the app's override. The Live Activity render cache is keyed on the whole locale environment. Add setDynamicWidgetLocale(tag | null): the tag is stored in the App Group defaults, surfaced as env.appLocale, and all timelines reload. It rejects without a groupIdentifier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
… app override Dynamic Widgets now receive the whole configured locale list, layout direction, the user's 12/24-hour setting, time zone, calendar, first weekday and (API 28+) measurement system. A LOCALE_CHANGED receiver declared in the library manifest re-renders placed Dynamic Widgets after a device, per-app or regional change, and VoltraModule's configuration callback now also reacts to a locales change while the process lives. Add setDynamicWidgetLocale(tag | null), stored in SharedPreferences, surfaced as env.appLocale, and followed by a Dynamic Widget reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
…on languages iOS extension (ADR 0008 §4): the generated Info.plist now carries CFBundleLocalizations built from the Expo `locales` keys, ios.infoPlist.CFBundleLocalizations and every widget locale map (plus en), and each declared language gets a <locale>.lproj/Localizable.strings added to the extension target, so Locale.current and \.locale in the extension resolve like they do in the app. Edit Widget sheet (§6): appIntent.parameters[].title and a new configurationTitle accept locale maps, and parameters accept static `options`. Locale-mapped titles are emitted as bare string-literal keys (voltra_widget_<id>_param_<name>_title, ..._intent_title, ..._param_<name>_option_<value>) resolved from Localizable.strings with English fallback; options generate an AppEnum picker whose raw value is what env.configuration receives. Android writes the same keys into voltra_widgets.xml. Shared validation lives in @use-voltra/expo-plugin, and the voltra CLI generators mirror all of it. Also match localized files in an existing widget PBXGroup by file reference rather than basename, since every .lproj folder holds a file of the same name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
withVoltra(config, { androidIntlPolyfills: { locales } }) generates a
prelude that loads the FormatJS polyfills Hermes lacks (getCanonicalLocales,
Locale, PluralRules, ListFormat, DisplayNames, RelativeTimeFormat) with
locale data for the listed languages, and imports it only from the
.android.js render shim so iOS bundles stay polyfill-free. The packages
are not bundled with Voltra; enabling the option without them installed
fails with a message naming what to add. The options ride on the returned
Metro config so the release bundler applies them too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
New iOS and Android Localisation pages cover the locale environment, resolveLocale, the Android Intl polyfills, setDynamicWidgetLocale, extension languages and localised gallery and configuration copy. The Dynamic Widgets, Dynamic Live Activities, Configurable Widgets and plugin configuration pages link to them, and examples (including the example app's demo widgets) no longer hard-code 'en-US'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
setDynamicWidgetLocale now also re-sends each running Dynamic Live Activity its current content, so it renders again with the new env.appLocale straight away rather than on its next update. It goes through the registry's reload directly: reloadDynamicLiveActivities is DEBUG-only because it also refetches bundles from Metro. Build the h23 and Japanese-calendar test locales with Locale.Components instead of relying on undocumented BCP-47 parsing in Locale(identifier:), and log a debug line when the Android locale-change receiver starts a reload so ADR test T3 can be confirmed from logcat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
The widget extension's declared languages and the Localizable.strings fallback now use the app's CFBundleDevelopmentRegion (from ios.infoPlist in the Expo plugin, from the app Info.plist in the CLI), falling back to en when it is unset or a build-setting reference. A missing translation resolves to the development language, then English, so no key shows verbatim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Reorganises the iOS and Android Localisation pages around what a widget author wants to do, leads each section with the outcome, moves prerequisites and failure cases up front, and removes engine and build-output details that the reader does not need. Aligns the related Dynamic Widgets, Configurable Widgets, Live Activities and plugin configuration pages and the changesets with the same wording. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
V3RON
force-pushed
the
claude/stoic-ride-vbu1tq
branch
from
September 27, 2026 14:41
5335b05 to
bb588ff
Compare
Apple's WidgetConfigurationIntent documentation says the system uses the intent's title only as the widget's display name when the AppIntentConfiguration does not set one explicitly. Voltra always sets configurationDisplayName, so the Edit Widget sheet header is the display name (confirmed on iOS 18.0) and a localised intent title has no visible surface. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
The device run could not find the row on a simulator with a single preferred language. WWDC24 session 10185 states the row appears only with more than one language in Language & Region unless the app sets UIPrefersShowingLanguageSettings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
…de a live Glance session reloadClientWidgets called a bare GlanceAppWidget.update. A Glance session stays open for 45 s after a render, and update on an open session only recomposes what read a changed state key, so a locale, per-app language, setDynamicWidgetLocale or colour-scheme change within that window never re-ran the JS render. Route the reload through the revision-advancing trigger the props path already uses. Found in the Android device run (API 36): changes spaced 60 s apart rendered, changes 4 to 29 s after a render were dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
…e layout direction Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
- Use American spelling (Localization) to match the rest of the site, and rename the pages to localization.md. - Quote the error setDynamicWidgetLocale rejects with on iOS when groupIdentifier is missing, and put the requirement before the example. - Explain the formatting fields staying put without launcher/stack jargon. - Say that AppCompat per-app languages on Android 12 and older do not reach widgets, that Intl.Segmenter is not polyfilled, and that the configuration string resources have no JavaScript API. - Document the prebuild errors for invalid appIntent options, and that option titles are required. - Say resolveLocale's last fallback is the alphabetically first key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MUtVX8GiMwbgsMawvajAd
Renumber the localisation ADR from 0008 to 0009: main took 0008 for the Android ongoing notification Live Updates API (#325).
V3RON
added a commit
that referenced
this pull request
Sep 28, 2026
…-options Resolves the conflicts with the Live Updates work (#325) and the Metric layout (#326), which landed overlapping pieces of this PR: - A countdown is expressed with #325's `chronometer: 'countDown'`; the separate `chronometerCountDown` prop is dropped. The payload key is unchanged, so the Kotlin side and remote payloads are unaffected, and `showWhen` layers on top of #325's timestamp handling. - Errors go through #325's VoltraNotificationException path. An unresolvable `color` now rejects with VOLTRA_NOTIFICATION_INVALID_OPTIONS; the promoted-unavailable case is #325's VOLTRA_NOTIFICATION_NOT_PROMOTABLE. - `showWhen` and `publicVersion` move into normalizeCommonDisplayFields and the Metric payload, so every kind carries them. - This PR's manager tests move to VoltraNotificationManagerPresentationTest, since #325 took VoltraNotificationManagerTest for its per-API suite. - The field-placement ADR is renumbered to 0010: main took 0008 and #332 holds 0009.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this?
Dynamic Widgets and Dynamic Live Activities can now be translated and formatted for the person looking at them, on iOS and Android, from the same JavaScript that draws them. Until now a widget author had
env.localeand little else, and on iOS that value was not usable: it arrived aspl_PL, whichIntlrejects with aRangeError, and it followed whichever languages happened to have a translated gallery label rather than the languages the app supports. Android widgets kept their old language after the user changed it, and severalIntlAPIs that i18n libraries depend on are missing there.This lands ADR 0009, which records the investigation, the platform facts behind each decision, and the device tests (T1 to T7) that gate the release. Both platforms were exercised with the example app: an iOS 18.0 simulator and an API 36 emulator. Locale environment, system language change with the app not running, per-app language,
setDynamicWidgetLocalewith a running Live Activity, RTL, the translated Edit Widget sheet and the generated Android resources all behaved as documented. Swift and Kotlin were not compiled in the authoring environment; CI covers that.How does it work?
env.localeis a BCP-47 tag on iOS too, and both platforms addpreferredLanguages,appLocale,layoutDirection,hourCycle(matching the user's 12/24-hour setting),timeZone,measurementSystem,calendarandfirstDayOfWeek. Live Activities receive the same fields.updateon a session that is still open (45 seconds after a render) recomposes nothing otherwise.setDynamicWidgetLocale(tag | null)on both clients lets an app with its own language picker choose the widget language. It reaches every render asenv.appLocale; widgets and running Live Activities render again immediately. It changes the translation only; the formatting fields and layout direction keep followinglocale. On iOS it needsgroupIdentifier.localesconfig,CFBundleLocalizations, widget locale maps and the app's development language, soenv.localeresolves to the language the app runs in.resolveLocale(env, messages)andpickLocalizedValuemove to@use-voltra/coreand are exported from@use-voltra/iosand@use-voltra/android, with the same fallback order as localized initial states.Intlgaps are covered by an opt-inwithVoltra(config, { androidIntlPolyfills })option that adds the FormatJS polyfills to Android widget bundles only.appIntent.parameters[].titleaccepts locale maps, and parameters accept staticoptions, shown as a picker on iOS. The sheet header is the widget'sdisplayName, which the existing locale map already translates. Android stores the same strings as resources for an in-app configuration screen. The CLI mirrors all of it.Why is this useful?
One JavaScript entry now renders correctly in every language and format the device is set to, with no per-language bundles, no native string tables for widget content, and no stale widgets after a language change. The two surfaces the operating system draws itself, the gallery and the Edit Widget sheet, are translated from the same
app.jsonlocale maps.