Skip to content

feat(android): day/night color pairs on Android Dynamic Widgets - #356

Open
konstmar wants to merge 34 commits into
callstackincubator:mainfrom
konstmar:feat/android-color-pairs
Open

konstmar wants to merge 34 commits into
callstackincubator:mainfrom
konstmar:feat/android-color-pairs

Conversation

@konstmar

Copy link
Copy Markdown

What is this?

Android Dynamic Widget colors can now be a { day, night } pair: one color for the light system theme and one for the dark. Until now a color was a single string or an AndroidDynamicColors role, and the only way to change colors with the theme was to branch on env.colorScheme, which runs the widget's JavaScript again on every theme change.

Pairs work wherever a color string does: style colors, component color props, chart mark colors and foregroundStyleScale, and arc gradientColors. backgroundImage takes a pair of whole gradients.

This is the color-pair half of #319. The app color scheme mapped onto GlanceTheme comes in a separate PR.

How does it work?

<VoltraAndroid.Column style={{ backgroundColor: { day: '#ffffff', night: '#1c1b1f' } }}>
  <VoltraAndroid.Text style={{ color: { day: '#1c1b1f', night: '#e6e1e5' } }}>Inbox</VoltraAndroid.Text>
</VoltraAndroid.Column>

The pair travels through the payload unchanged, and the native side picks the side to show:

  • On Android 12+, a pair becomes Glance's day/night color provider. The launcher gets both colors and swaps them itself on a theme change, even when the app isn't running.
  • Below Android 12, and for anything drawn into a bitmap (gradients, arcs, custom-font text, charts), the side is picked when the widget is composed. A theme change needs a native recomposition, but no JavaScript.
  • Each side takes any literal color, alpha included. Role tokens aren't allowed inside a pair, and an invalid pair is dropped as a whole, so the property falls back to its default.

Differences from #319:

  • The keys are { day, night } instead of { light, dark }, as agreed with the maintainer.
  • Scaffold backgroundColor, the Text color prop and chart colors also take pairs. The Text color prop is now applied, and it wins over the style's color.
  • An unparsable hex, RGB or HSL side logs two warnings instead of one.

Why is this useful?

Widget authors can give a widget colors for both themes without branching on env.colorScheme. On Android 12+ the colors stay right after a theme change even when the app isn't running.

Refs #319

Konstantin Maruschak added 30 commits October 16, 2026 14:27
Define AndroidColorPair, StaticColor and AndroidColorValue once next to the
dynamic colours and export AndroidColorPair from the package root. The
modifiers module re-exports it as DayNightColors (alongside StaticColor), so
the 2.4.0 paths keep working and background keeps rejecting role tokens inside
a pair at compile time. background's payload is unchanged.

Add a type-test project (packages/android/type-tests) with @ts-expect-error
cases, run by the package's typecheck script.
A style backgroundColor, text color, borderColor or shadowColor can be
{ day, night }. The colour model gains a DayNight variant that maps to
Glance's public day/night ColorProvider, so from Android 12 the launcher
swaps the colours itself; below that composition picks the side from the
configuration's night bit. An invalid pair is dropped with one warning
naming the style key.

Adds the native compose/apply test seam (NativeComposeSeam) that later
colour-pair tests reuse.
…t demo

The card sits in one new Row so the root Column stays under Glance's
10-child limit; later colour-pair demos join that Row.
…rt names

A "color" parameter emits AndroidColorValue in the generated Android JSX props and
@contextual Any? in the Kotlin parameter classes (which are @serializable, so a bare Any
does not compile). Short-name validation now rejects "day" and "night", the keys of a
colour pair, which decompression would otherwise expand.
Every Android component colour prop is now a "color" parameter, so it accepts a
{ day, night } pair as well as a colour string or role token. Regenerated with pnpm generate.
…ur props

Renderers hand the raw prop value to the colour parser instead of casting it to a String,
so a pair reaches the View and an invalid one is dropped with a warning naming the prop.
TitleBar and Scaffold no longer crash the composition on a non-string colour, and the Text
color prop is now read (it wins over the style colour).
…mage pair

Style conversion parses both sides of a `{ day, night }` backgroundImage pair and
rendering draws the side for the composing context's night bit. A pair with a missing
or unparsable side is dropped with one warning naming the key, so backgroundColor
shows instead. Seam tests also pin that token stops draw a distinct bitmap per scheme
and that the gradient cache never hands the day bitmap to the night scheme.
ArcProgressIndicator's gradientColors now accepts colour strings, role
tokens and { day, night } pairs as elements. The prop is still sent as a
JSON-encoded array.
…s pairs

The gradientColors list parser now decodes each element dynamically, so a
{ day, night } object reaches the colour parser and the arc bitmap is drawn
with the current scheme's side. An invalid pair drops the gradient, as an
unparsable string does, and the single warning names gradientColors.

Arc bitmaps stay keyed by resolved ARGB and the cache clear on a scheme flip
is unchanged. Custom-font text needs no change: its only caller resolves the
colour to a static one before drawing; a seam test now covers it.
…roundStyleScale

Chart mark colours are resolved during composition by parsing the raw
`c` value, so a `{ day, night }` pair resolves to the current scheme's
hex before the bitmap renderer reads it. `foregroundStyleScale` entries
now decode dynamically, keeping the series key a string and passing the
raw colour to the parser. An invalid pair falls back to the default
palette with one warning naming the prop.

`foregroundStyleScale` is typed `Record<string, AndroidColorValue>`.
…me it for color values

parseColorStringList returns strings or { day, night } maps now, so it becomes parseColorValueList.
…helper

Side detection, the description of the rejected value and the warning text now live in parseDayNightPair.
…nt or a day/night pair

VoltraBackgroundImage mirrors VoltraColorValue.DayNight, so callers resolve the gradient for the scheme instead of choosing between two nullable fields.
…r provider mapping

VoltraColorValue.toColorProvider(resolveRole) holds the one static and day/night mapping; the modifier scope passes its own theme-role resolver, so its theme-role behavior is unchanged.
…names it

JSColorParser.parse keeps its optional key: modifier, notification, gradient-stop and chart-bitmap callers still omit it.
…arsing the gradient

StyleConverter.convert takes isNightMode (default false) and narrows a
{ day, night } backgroundImage pair to one CSS string, which the existing
gradient parser then reads. resolveElementStyle passes the composing
context's night bit. A pair whose sides are missing, empty or not strings
is still dropped with one warning; a typo in the unselected side no longer
drops the selected one.

DecorationStyle.backgroundImage is a BackgroundImageValue again, and
VoltraBackgroundImage, resolveGradient and JSGradientParser.parsePair are
gone.
The color list parser keeps its name from main so RendererJsonTest is unchanged; its KDoc says the
elements can be { day, night } maps as well as strings.
Konstantin Maruschak added 4 commits October 18, 2026 23:10
…t files

Cases whose production unit already has a test file on main move there as new methods:
gradient backgrounds into GradientBitmapRendererTest, arc gradient colors into
ArcBitmapRendererTest, chart colors into ChartBitmapRendererTest, the modifier scope into
VoltraModifierRegistryTest and backgroundImage pair conversion into StyleConverterTest. The rest
stay in new files named after the unit they test: JSColorParserTest, VoltraColorValueTest,
StyleModifiersTest, GlanceFactoryTest and TextAndImageRenderersTest. No case changes and the test
count is unchanged.
Drop helpers and tests the issue does not ask for, cover arc color pairs and decompressed style pairs, and shorten the pair docs.
Type every Android component color prop as a color value that links to the day and night colors section.
@konstmar

Copy link
Copy Markdown
Author

Emulator evidence

Test layout: the AndroidClientDemoWidget Dynamic Widget on the home screen has a Row with one demo per kind of pair:

Demo Day Night
Style pair card (background / text) #fff3c4 / #5d4037 #1a237e / #ffe082
Text color prop pair #c62828 #80cbc4
backgroundImage gradient pair #ff8f00 → #ffee58 #311b92 → #00bfa5
Arc gradientColors pairs #ef6c00 → #6a1b9a #80deea → #f48fb1

Colors were sampled from screencaps by counting pixels within a channel delta of 12 of each expected color. The widget shows its render time, which tells a launcher-side swap apart from a recomposition.

Build: a debug APK of the example app from this branch @ 2bfeef6 (arm64-v8a), with Metro serving the widget bundle.

Android 15 emulator, API 35 (AVD Pixel_9)

Step Result Render time
Light, app running every demo on its day side, no night pixels 5:06:00 PM
Dark, app running every demo on its night side, no day pixels 5:07:25 PM
Light again same pixel counts as the first light run 5:07:56 PM
App process killed, then dark card and prop pair switch to night; gradient and arc keep day 5:07:56 PM, unchanged

With no app process and no new render, the launcher swaps the plain pair colors itself. The gradient and arc are bitmaps, so they keep the side they were drawn with until the widget recomposes.

Android 7.0 emulator, API 24 (AVD Medium_Phone_2)

Step Result Render time
Light, app running every demo on its day side, no night pixels 5:32:25 PM
Dark, app running every demo on its night side, no day pixels 5:34:05 PM
Light again same pixel counts as the first light run 5:34:46 PM
App process killed, then light every pair keeps night 5:35:21 PM, unchanged
Widget refresh every demo back on its day side 5:47:43 PM

Below Android 12 the side is picked when the widget is composed, so a theme change needs a recomposition. Refreshing the widget recomposes it, and it picks the side from the new theme. That a recomposition alone is enough, with no JavaScript render, is covered by the API 26 test in StyleModifiersTest.

Charts aren't placed in the demo widget, so they are covered by the ChartBitmapRendererTest pixel tests only.

Steps:

  1. Start Metro from example/ on port 8104.
  2. Build ./gradlew :app:assembleDebug -PreactNativeArchitectures=arm64-v8a -PreactNativeDevServerPort=8104 and adb install -r it.
  3. Flip the theme:
    • 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
  4. With the app backgrounded, kill its process: adb shell am kill <package> on API 35, adb shell run-as <package> kill <pid> on API 24.
  5. After each step, press HOME, then adb exec-out screencap -p.

Unit tests: pnpm test:kotlin (670 tests), pnpm test, pnpm typecheck (including the type tests), pnpm lint and pnpm format:kotlin:check pass, and so do the pre-push test-js/test-kotlin hooks.

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.

1 participant