Skip to content

feat: localisation for Dynamic Widgets and Dynamic Live Activities - #332

Merged
V3RON merged 20 commits into
mainfrom
claude/stoic-ride-vbu1tq
Sep 28, 2026
Merged

V3RON merged 20 commits into
mainfrom
claude/stoic-ride-vbu1tq

Conversation

@V3RON

@V3RON V3RON commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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.locale and little else, and on iOS that value was not usable: it arrived as pl_PL, which Intl rejects with a RangeError, 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 several Intl APIs 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, setDynamicWidgetLocale with 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?

  • A complete locale environment on both platforms. env.locale is a BCP-47 tag on iOS too, and both platforms add preferredLanguages, appLocale, layoutDirection, hourCycle (matching the user's 12/24-hour setting), timeZone, measurementSystem, calendar and firstDayOfWeek. Live Activities receive the same fields.
  • Widgets render again when the language changes. iOS does this on its own. On Android, placed widgets now render again after a device, per-app or regional language change, even with the app not running, and at once while it is. The reload advances each placement's Glance revision first, because update on 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 as env.appLocale; widgets and running Live Activities render again immediately. It changes the translation only; the formatting fields and layout direction keep following locale. On iOS it needs groupIdentifier.
  • Widgets declare the app's languages on iOS, from the Expo locales config, CFBundleLocalizations, widget locale maps and the app's development language, so env.locale resolves to the language the app runs in.
  • resolveLocale(env, messages) and pickLocalizedValue move to @use-voltra/core and are exported from @use-voltra/ios and @use-voltra/android, with the same fallback order as localized initial states.
  • Android Intl gaps are covered by an opt-in withVoltra(config, { androidIntlPolyfills }) option that adds the FormatJS polyfills to Android widget bundles only.
  • The Edit Widget sheet can be translated. appIntent.parameters[].title accepts locale maps, and parameters accept static options, shown as a picker on iOS. The sheet header is the widget's displayName, 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.
  • New Localization pages for iOS and Android, updated Dynamic Widgets, Configurable Widgets, Live Activities and plugin configuration pages, and changesets.

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.json locale maps.

V3RON and others added 12 commits September 27, 2026 12:27
…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
V3RON force-pushed the claude/stoic-ride-vbu1tq branch from 5335b05 to bb588ff Compare September 27, 2026 14:41
claude and others added 8 commits September 28, 2026 08:31
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
…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.
@V3RON
V3RON merged commit 8beedea into main Sep 28, 2026
16 checks passed
@V3RON
V3RON deleted the claude/stoic-ride-vbu1tq branch September 28, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants