Skip to content

Commit bb504f6

Browse files
authored
feat: support Android dynamic widget colors (#121)
## Changes Voltra now exposes Android dynamic widget colors as semantic tokens through `AndroidDynamicColors` in `voltra/android`. - add `AndroidDynamicColors` and `AndroidColorValue` to the Android API surface - resolve Android dynamic colors natively in widget rendering - update Android-only prop/style typings and generator sync to use `AndroidColorValue` - update Android examples and website docs to document dynamic colors and server-driven widget usage ## Test plan - [x] `npm run test:js --workspace voltra -- android-dynamic-color.node.test.tsx widget-server.node.test.tsx` - [x] `npm run build --workspace @use-voltra/android` - [x] `npm run typecheck --workspace @use-voltra/android` - [x] `npx ts-node packages/voltra/generator/generate-types.ts`
1 parent 6e75ae7 commit bb504f6

103 files changed

Lines changed: 1295 additions & 569 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

example/app.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,21 @@
128128
"intervalMinutes": 15,
129129
"refresh": true
130130
}
131+
},
132+
{
133+
"id": "material_colors",
134+
"displayName": "Material Colors Widget",
135+
"description": "Compare client-side and server-side rendering with Android dynamic colors",
136+
"targetCellWidth": 2,
137+
"targetCellHeight": 2,
138+
"resizeMode": "horizontal|vertical",
139+
"widgetCategory": "home_screen",
140+
"initialStatePath": "./widgets/android/android-material-colors-initial.tsx",
141+
"serverUpdate": {
142+
"url": "http://10.0.2.2:3333",
143+
"intervalMinutes": 15,
144+
"refresh": true
145+
}
131146
}
132147
]
133148
},
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import AndroidMaterialColorsScreen from '~/screens/android/AndroidMaterialColorsScreen'
2+
3+
export default function AndroidMaterialColorsRoute() {
4+
return <AndroidMaterialColorsScreen />
5+
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { useRouter } from 'expo-router'
2+
import React, { useState } from 'react'
3+
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
4+
import {
5+
reloadAndroidWidgets,
6+
requestPinAndroidWidget,
7+
setWidgetServerCredentials,
8+
updateAndroidWidget,
9+
VoltraWidgetPreview,
10+
} from 'voltra/android/client'
11+
12+
import { Button } from '~/components/Button'
13+
import { Card } from '~/components/Card'
14+
import {
15+
AndroidMaterialColorsWidget,
16+
type AndroidMaterialColorsRenderSource,
17+
} from '~/widgets/android/AndroidMaterialColorsWidget'
18+
19+
const WIDGET_ID = 'material_colors'
20+
const DEMO_TOKEN = 'demo-token'
21+
22+
const formatRenderTime = () =>
23+
new Date().toLocaleTimeString([], {
24+
hour: '2-digit',
25+
minute: '2-digit',
26+
second: '2-digit',
27+
})
28+
29+
export default function AndroidMaterialColorsScreen() {
30+
const router = useRouter()
31+
const [isPinning, setIsPinning] = useState(false)
32+
const [isRenderingClient, setIsRenderingClient] = useState(false)
33+
const [isRenderingServer, setIsRenderingServer] = useState(false)
34+
const [previewSource, setPreviewSource] = useState<AndroidMaterialColorsRenderSource>('initial')
35+
const [previewTimestamp, setPreviewTimestamp] = useState('waiting for render')
36+
37+
const handlePinWidget = async () => {
38+
if (Platform.OS !== 'android') {
39+
Alert.alert('Not Available', 'This widget demo is only available on Android devices.')
40+
return
41+
}
42+
43+
setIsPinning(true)
44+
try {
45+
const success = await requestPinAndroidWidget(WIDGET_ID, {
46+
previewWidth: 220,
47+
previewHeight: 220,
48+
})
49+
50+
if (success) {
51+
Alert.alert('Pin requested', 'Add the widget on your home screen, then use the render buttons below.')
52+
} else {
53+
Alert.alert('Not supported', 'Widget pinning is not available on this device.')
54+
}
55+
} catch (error: any) {
56+
const message = error?.message || String(error)
57+
Alert.alert('Error', `Failed to pin widget: ${message}`)
58+
} finally {
59+
setIsPinning(false)
60+
}
61+
}
62+
63+
const handleRenderOnClient = async () => {
64+
if (Platform.OS !== 'android') {
65+
Alert.alert('Not Available', 'Client-side widget rendering is only available on Android devices.')
66+
return
67+
}
68+
69+
setIsRenderingClient(true)
70+
try {
71+
const renderedAt = formatRenderTime()
72+
73+
await updateAndroidWidget(WIDGET_ID, [
74+
{
75+
size: { width: 200, height: 200 },
76+
content: <AndroidMaterialColorsWidget source="client" renderedAt={renderedAt} />,
77+
},
78+
{
79+
size: { width: 300, height: 200 },
80+
content: <AndroidMaterialColorsWidget source="client" renderedAt={renderedAt} />,
81+
},
82+
])
83+
84+
setPreviewSource('client')
85+
setPreviewTimestamp(renderedAt)
86+
Alert.alert(
87+
'Client render complete',
88+
'The widget JSON was rendered inside the app and pushed straight to Android.'
89+
)
90+
} catch (error: any) {
91+
const message = error?.message || String(error)
92+
Alert.alert('Error', `Failed to render on client: ${message}`)
93+
} finally {
94+
setIsRenderingClient(false)
95+
}
96+
}
97+
98+
const handleRenderOnServer = async () => {
99+
if (Platform.OS !== 'android') {
100+
Alert.alert('Not Available', 'Server-side widget rendering is only available on Android devices.')
101+
return
102+
}
103+
104+
setIsRenderingServer(true)
105+
try {
106+
await setWidgetServerCredentials({
107+
token: DEMO_TOKEN,
108+
headers: {
109+
'X-Widget-Source': 'voltra-example',
110+
},
111+
})
112+
113+
await reloadAndroidWidgets([WIDGET_ID])
114+
115+
setPreviewSource('server')
116+
setPreviewTimestamp('server timestamp')
117+
Alert.alert(
118+
'Server render requested',
119+
'The widget will fetch fresh JSON from the example server. Make sure `npm run widget:server --workspace voltra-example` is running on your host machine.'
120+
)
121+
} catch (error: any) {
122+
const message = error?.message || String(error)
123+
Alert.alert('Error', `Failed to render on server: ${message}`)
124+
} finally {
125+
setIsRenderingServer(false)
126+
}
127+
}
128+
129+
return (
130+
<View style={styles.container}>
131+
<ScrollView style={styles.scrollView} contentContainerStyle={styles.content}>
132+
<Text style={styles.heading}>Material Colors Widget</Text>
133+
<Text style={styles.subheading}>
134+
Test the same Android widget through both render paths. It uses Android semantic color tokens, so both
135+
client-side and server-side rendering resolve native Material You colors directly inside Glance.
136+
</Text>
137+
138+
<Card>
139+
<Card.Title>1. Pin the Widget</Card.Title>
140+
<Card.Text>
141+
Add the widget to your home screen once, then switch between client-side and server-side renders.
142+
</Card.Text>
143+
<View style={styles.buttonContainer}>
144+
<Button
145+
title={isPinning ? 'Requesting pin...' : 'Pin widget to home screen'}
146+
variant="primary"
147+
onPress={handlePinWidget}
148+
disabled={isPinning}
149+
/>
150+
</View>
151+
</Card>
152+
153+
<Card>
154+
<Card.Title>2. Choose the Render Path</Card.Title>
155+
<Card.Text>
156+
Both buttons target the same <Text style={styles.code}>{WIDGET_ID}</Text> widget. Use them to compare how
157+
Material dynamic colors flow through the client and server pipelines.
158+
</Card.Text>
159+
<View style={styles.actionsRow}>
160+
<Button
161+
title={isRenderingClient ? 'Rendering on client...' : 'Render on client'}
162+
variant="primary"
163+
onPress={handleRenderOnClient}
164+
disabled={isRenderingClient || isRenderingServer}
165+
style={styles.actionButton}
166+
/>
167+
<Button
168+
title={isRenderingServer ? 'Rendering on server...' : 'Render on server'}
169+
variant="secondary"
170+
onPress={handleRenderOnServer}
171+
disabled={isRenderingClient || isRenderingServer}
172+
style={styles.actionButton}
173+
/>
174+
</View>
175+
</Card>
176+
177+
<Card>
178+
<Card.Title>Preview</Card.Title>
179+
<Card.Text>
180+
This in-app preview mirrors the widget design. The home screen widget is the real test, but this makes it
181+
easier to see which render path you triggered last.
182+
</Card.Text>
183+
<View style={styles.previewContainer}>
184+
<VoltraWidgetPreview family="mediumSquare" style={styles.previewFrame}>
185+
<AndroidMaterialColorsWidget source={previewSource} renderedAt={previewTimestamp} />
186+
</VoltraWidgetPreview>
187+
</View>
188+
</Card>
189+
190+
<Card>
191+
<Card.Title>Server Setup</Card.Title>
192+
<Card.Text>Run the example widget server before using the server render button:</Card.Text>
193+
<View style={styles.codeBlock}>
194+
<Text style={styles.codeText}>npm run widget:server --workspace voltra-example</Text>
195+
</View>
196+
<Card.Text>
197+
Android emulators use <Text style={styles.code}>10.0.2.2</Text> in the widget config, so the built-in
198+
server-driven refresh hits your host machine automatically.
199+
</Card.Text>
200+
</Card>
201+
202+
<View style={styles.footer}>
203+
<Button title="Back to Android Home" variant="ghost" onPress={() => router.push('/android-widgets')} />
204+
</View>
205+
</ScrollView>
206+
</View>
207+
)
208+
}
209+
210+
const styles = StyleSheet.create({
211+
container: {
212+
flex: 1,
213+
},
214+
scrollView: {
215+
flex: 1,
216+
},
217+
content: {
218+
paddingHorizontal: 20,
219+
paddingVertical: 24,
220+
},
221+
heading: {
222+
fontSize: 24,
223+
fontWeight: '700',
224+
color: '#FFFFFF',
225+
},
226+
subheading: {
227+
fontSize: 14,
228+
lineHeight: 20,
229+
color: '#CBD5F5',
230+
marginBottom: 8,
231+
},
232+
buttonContainer: {
233+
marginTop: 16,
234+
},
235+
actionsRow: {
236+
flexDirection: 'row',
237+
gap: 12,
238+
marginTop: 16,
239+
},
240+
actionButton: {
241+
flex: 1,
242+
},
243+
previewContainer: {
244+
alignItems: 'center',
245+
justifyContent: 'center',
246+
padding: 24,
247+
marginTop: 8,
248+
backgroundColor: '#0F172A',
249+
borderRadius: 12,
250+
},
251+
previewFrame: {
252+
borderRadius: 28,
253+
},
254+
code: {
255+
fontFamily: 'Courier',
256+
fontSize: 12,
257+
color: '#60A5FA',
258+
backgroundColor: '#0F172A',
259+
paddingHorizontal: 4,
260+
paddingVertical: 2,
261+
borderRadius: 4,
262+
},
263+
codeBlock: {
264+
backgroundColor: '#0F172A',
265+
borderRadius: 12,
266+
padding: 16,
267+
marginTop: 12,
268+
},
269+
codeText: {
270+
color: '#E2E8F0',
271+
fontFamily: 'Courier',
272+
fontSize: 12,
273+
},
274+
footer: {
275+
marginTop: 24,
276+
alignItems: 'center',
277+
},
278+
})

example/screens/android/AndroidScreen.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ const ANDROID_SECTIONS = [
4848
'Serve dynamic widget content from a remote server using Voltra SSR. This example includes a sample widget server implementation.',
4949
route: '/android-widgets/server-driven',
5050
},
51+
{
52+
id: 'material-colors',
53+
title: 'Material Colors',
54+
description:
55+
'Test one Android widget through both client-side and server-side rendering, using Material dynamic colors from the current device theme.',
56+
route: '/android-widgets/material-colors',
57+
},
5158
{
5259
id: 'custom-fonts',
5360
title: 'Custom Fonts',

example/server/widget-server.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createServer } from 'node:http'
1111
import React from 'react'
1212
import { createWidgetUpdateNodeHandler } from 'voltra/server'
1313
import { IosPortfolioWidget } from '../widgets/ios/IosPortfolioWidget'
14+
import { AndroidMaterialColorsServerWidget } from '../widgets/android/AndroidMaterialColorsWidget'
1415
import { AndroidPortfolioWidget } from '../widgets/android/AndroidPortfolioWidget'
1516

1617
const PORTFOLIO_TIMES = [
@@ -47,6 +48,10 @@ function generatePortfolioData() {
4748

4849
const handler = createWidgetUpdateNodeHandler({
4950
renderIos: async (req: any) => {
51+
if (req.widgetId !== 'portfolio') {
52+
return null
53+
}
54+
5055
const now = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
5156
const { chartData, change, balance } = generatePortfolioData()
5257
const isPositive = change >= 0
@@ -65,6 +70,22 @@ const handler = createWidgetUpdateNodeHandler({
6570

6671
renderAndroid: async (req: any) => {
6772
const now = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
73+
74+
if (req.widgetId === 'material_colors') {
75+
console.log(`[${now}] [Android] Rendering material colors widget`)
76+
77+
const content = <AndroidMaterialColorsServerWidget renderedAt={now} />
78+
79+
return [
80+
{ size: { width: 200, height: 200 }, content },
81+
{ size: { width: 300, height: 200 }, content },
82+
]
83+
}
84+
85+
if (req.widgetId !== 'portfolio') {
86+
return null
87+
}
88+
6889
const { chartData, change, balance } = generatePortfolioData()
6990
const isPositive = change >= 0
7091
const changeText = `${isPositive ? '+' : ''}${change.toFixed(1)}%`
@@ -91,6 +112,8 @@ createServer(handler).listen(PORT, () => {
91112
console.log(`\n Portfolio chart:`)
92113
console.log(` iOS: GET http://localhost:${PORT}?widgetId=portfolio&platform=ios&family=systemSmall`)
93114
console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=portfolio&platform=android`)
115+
console.log(`\n Material colors:`)
116+
console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=material_colors&platform=android`)
94117
console.log(`\n (Android emulator uses 10.0.2.2 to reach the host machine)`)
95118
console.log(`\nEach request generates randomized portfolio data.`)
96119
console.log(`Press Ctrl+C to stop.\n`)

0 commit comments

Comments
 (0)