Skip to content

feat(android): let a Dynamic Widget export a theme for its color tokens - #357

Open
konstmar wants to merge 2 commits into
callstackincubator:mainfrom
konstmar:feat/android-widget-theme
Open

konstmar wants to merge 2 commits into
callstackincubator:mainfrom
konstmar:feat/android-widget-theme

Conversation

@konstmar

Copy link
Copy Markdown

What is this?

An Android Dynamic Widget can now export a theme next to its render function. The theme gives day and night colors for any AndroidDynamicColors role, and the widget's role tokens then resolve to those colors.

Until now, role tokens always meant Glance's own palette: Material You on Android 12+, and Glance's fixed purple baseline below that. An app had no way to map the roles to its brand colors, so on older devices every widget looked like a stock Material demo.

This is the theme half of #319. Color pairs come in #356.

How does it work?

export const theme: AndroidWidgetTheme = {
  day: { primary: '#E65100', surface: '#FFF3E0' },
  night: { primary: '#FFB74D', surface: '#3E2723' },
  dynamicColors: false, // optional: let the theme win over Material You on Android 12+ too
}

export default function OrderWidget(props, env) {
  // AndroidDynamicColors.primary and .surface now resolve to the theme
}
  • The generated Metro entry exposes the module's theme as JSON. The native side reads it each time the bundle is evaluated, so editing or removing the theme takes effect on the next Metro reload.
  • The widget's tree is wrapped in a GlanceTheme built from the theme. Each role defined on both sides becomes a day/night color, and roles the theme leaves out keep the palette they have today. Native modifiers such as background(AndroidDynamicColors.surface) follow the theme too.
  • By default the system palette still wins on Android 12+, so the theme only fills in below Android 12. dynamicColors: false makes the theme win everywhere.
  • Literal colors and { day, night } pairs are always used as written. Payload-driven widgets and the prerendered placeholder don't see the theme.
  • A theme mistake never blanks the widget. A theme that isn't an object is ignored with a warning in Logcat, and a role that is one-sided or isn't a literal color is dropped with a warning.

Differences from #319:

  • The theme's maps are named day and night, matching the pair keys from feat(android): day/night color pairs on Android Dynamic Widgets #356.
  • Roles the theme leaves out keep the palette in scope outside the wrapper, rather than DynamicThemeColorProviders directly, because that API is restricted.
  • The issue assumed the module's exports were already readable from Kotlin. The generated entry only exposed render, so this adds getThemeJSON on the JS side and nativeThemeJSON on the native side.
  • "One warning" for a malformed theme holds per Glance session and widget size, so a session usually logs it twice. An unparsable hex, RGB or HSL value logs a second warning from the color parser, as in feat(android): day/night color pairs on Android Dynamic Widgets #356.

Why is this useful?

Apps can give their widgets a brand palette that works on every Android version, without replacing role tokens with literal colors. On Android 11 and older, widgets no longer fall back to Glance's purple baseline. With dynamicColors: false, the brand palette also replaces Material You on newer devices.

Refs #319

@konstmar

konstmar commented Oct 20, 2026 •

Copy link
Copy Markdown
Author

Emulator evidence

Test layout: the AndroidClientDemoWidget Dynamic Widget on the home screen exports this theme:

export const theme: AndroidWidgetTheme = {
  day: { primary: '#E65100', surface: '#FFF3E0' },
  night: { primary: '#FFB74D', surface: '#3E2723' },
  dynamicColors: false,
}

The widget background uses AndroidDynamicColors.surface through a native background(...) modifier, and a marker uses AndroidDynamicColors.primary. For each scenario the theme was edited temporarily, the marker text was set to the scenario's name, and a literal #ff0000 line was added to check that hard-coded colors ignore the theme. Colors were read from screencaps taken during each clip, by counting pixels of each exact hex value.

Every clip runs day → night → day on the home screen. In every clip #FF0000 stays red, and text that uses roles the theme leaves out (onSurface, onSurfaceVariant) has the same color as in the no-theme clip.

Build: a debug APK of the example app from this branch @ 4eacc5d, with Metro serving the widget bundle.

Devices: Android 7.0 emulator, API 24 (AVD Medium_Phone_2), and Android 15 emulator, API 35 (AVD Pixel_9).

dynamicColors: false: the theme wins on every API level

API 24: surface #FFF3E0 → #3E2723, marker #E65100 → #FFB74D

api24-dyn-false.webm

API 35: the same colors, so the theme also wins over Material You

api35-dyn-false.mp4

dynamicColors left at its default

API 24: the theme still applies below Android 12 (same colors as above)

api24-dyn-default.webm

API 35: the system palette wins (surface #FEFBFF → #1A1B20, marker #475D92 → #B0C6FF), the same as with no theme

api35-dyn-default.mp4

A theme that isn't an object

export const theme = (() => null) as unknown as AndroidWidgetTheme. The widget renders exactly as with no theme, and Logcat shows W DynamicWidgetTheme: Ignoring the widget theme: expected an object with day and night, got "function".

API 24

api24-malformed.webm

API 35

api35-malformed.mp4

A role defined on one side only

primary removed from night. The role is dropped with W DynamicWidgetTheme: Ignoring theme role primary: it is only defined for day, and surface keeps the theme.

API 24: surface #FFF3E0 → #3E2723, marker falls back to the baseline #6750A4 → #D0BCFF

api24-one-sided-role.webm

API 35: surface #FFF3E0 → #3E2723, marker falls back to Material You #475D92 → #B0C6FF

api35-one-sided-role.mp4

No theme

The theme export removed. No DynamicWidgetTheme lines are logged.

API 24: Glance baseline, surface #FFFBFE → #1C1B1F, marker #6750A4 → #D0BCFF

api24-no-theme.webm

API 35: Material You, surface #FEFBFF → #1A1B20, marker #475D92 → #B0C6FF

api35-no-theme.mp4

Steps:

  1. Build the JS packages (pnpm turbo run build --filter='./packages/*'), then start Metro from example/ with npx expo start --port 8104 --non-interactive.
  2. Build ./gradlew :app:installDebug -PreactNativeDevServerPort=8104 and adb reverse tcp:8104 tcp:8104.
  3. Open the app and tap Reload Android widgets.
  4. Flip the scheme:
    • API 35: adb shell cmd uimode night yes|no
    • API 24, which has no cmd uimode: adb shell service call uimode 4 i32 2 for night, i32 1 for day
  5. Record:
    • API 35: agent-device record start <file>.mp4 --scope device
    • API 24: the guest screenrecord stops when night mode flips, so the clips come from the emulator's recorder: adb emu screenrecord start --size 540x1200 --bit-rate 500000 --fps 10 <file>.webm

These clips replace the Robolectric DynamicWidgetThemedContentTest and its GlanceRenderHelper. DynamicWidgetThemeTest still covers theme parsing. Unit tests: pnpm test:kotlin (650 tests) and pnpm format:kotlin:check pass.

@Angelk90

Copy link
Copy Markdown
Contributor

@konstmar :

If I want to use different colors for day and night, in both light and dark modes, that’s possible.

So, we would have four variants: dayLight, nightLight, dayDark, and nightDark.

@konstmar
konstmar marked this pull request as ready for review October 20, 2026 09:56
Konstantin Maruschak added 2 commits October 27, 2026 13:35
A Dynamic Widget can export a theme with day and night colors for any AndroidDynamicColors role, and its role tokens then resolve to those colors. dynamicColors: false lets the theme win over Material You on Android 12+.
Covers the theme export, its precedence over dynamic colors, and how malformed themes are handled.
@konstmar
konstmar force-pushed the feat/android-widget-theme branch from 15f4874 to a8c97e9 Compare October 27, 2026 12:40

This branch has not been deployed

No deployments
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