Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"@use-voltra/android-client",
{
"enableNotifications": true,
"widgetConfigurationRoute": "voltraui/android-widget-config",
"widgets": [
{
"id": "voltra",
Expand Down
93 changes: 91 additions & 2 deletions example/app/voltraui/[activityName].tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,84 @@
import { useLocalSearchParams, useRouter } from 'expo-router'
import { StyleSheet, Text, View } from 'react-native'
import { useState } from 'react'
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native'
import {
cancelWidgetConfiguration,
completeWidgetConfiguration,
setWidgetInstanceConfiguration,
} from '@use-voltra/android-client'

import { Button } from '~/components/Button'

export default function DeepLinkIndexScreen() {
const { activityName } = useLocalSearchParams<{ activityName: string }>()
const { activityName, appWidgetId, widgetId } = useLocalSearchParams<{
activityName: string
appWidgetId?: string
widgetId?: string
}>()
const router = useRouter()
const isWidgetConfig = activityName === 'android-widget-config'
const parsedAppWidgetId = typeof appWidgetId === 'string' ? Number(appWidgetId) : Number.NaN
const [label, setLabel] = useState('Hello')
const [saving, setSaving] = useState(false)

const saveWidgetConfiguration = async () => {
if (!Number.isFinite(parsedAppWidgetId)) {
Alert.alert('Missing widget id', 'The configuration activity did not receive a valid appWidgetId.')
return
}

setSaving(true)
try {
await setWidgetInstanceConfiguration(parsedAppWidgetId, 'label', label)
await completeWidgetConfiguration(parsedAppWidgetId)
} catch (error: any) {
Alert.alert('Error', error?.message || String(error))
} finally {
setSaving(false)
}
}

const goBack = () => {
if (isWidgetConfig) {
cancelWidgetConfiguration().catch(() => {})
return
}

if (router.canGoBack()) {
router.back()
} else {
router.replace('/')
}
}

if (isWidgetConfig) {
return (
<View style={[styles.root]}>
<View style={styles.content}>
<Text style={styles.title}>Configure widget instance</Text>
<Text style={styles.activityText}>Widget type: {widgetId}</Text>
<Text style={styles.activityText}>Instance: #{appWidgetId}</Text>

<View style={styles.form}>
<Text style={styles.label}>env.configuration.label</Text>
<TextInput
style={styles.input}
value={label}
onChangeText={setLabel}
placeholder="Label"
placeholderTextColor="#64748B"
/>
</View>

<View style={styles.buttonRow}>
<Button title={saving ? 'Saving...' : 'Save'} onPress={saveWidgetConfiguration} disabled={saving} />
<Button title="Cancel" variant="secondary" onPress={goBack} />
</View>
</View>
</View>
)
}

return (
<View style={[styles.root]}>
<View style={styles.content}>
Expand Down Expand Up @@ -42,6 +106,31 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: 16,
},
form: {
width: '100%',
gap: 8,
},
label: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
input: {
minWidth: 260,
width: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.18)',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
color: 'white',
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
title: {
color: 'white',
fontSize: 24,
Expand Down
161 changes: 139 additions & 22 deletions example/screens/android/AndroidWidgetPinScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useRouter } from 'expo-router'
import React, { useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { Alert, Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import { requestPinAndroidWidget, setWidgetConfiguration } from '@use-voltra/android-client'
import {
getActiveWidgets,
requestPinAndroidWidget,
setWidgetInstanceConfiguration,
type WidgetInfo,
} from '@use-voltra/android-client'

import { Button } from '~/components/Button'
import { ScreenLayout } from '~/components/ScreenLayout'
Expand Down Expand Up @@ -37,28 +42,85 @@ const AVAILABLE_WIDGETS = [
},
]

const CONFIGURABLE_WIDGET_ID = 'AndroidClientDemoWidget'

export default function AndroidWidgetPinScreen() {
const router = useRouter()
const [selectedWidgetId, setSelectedWidgetId] = useState<string>('voltra')
const [previewWidth, setPreviewWidth] = useState<string>('250')
const [previewHeight, setPreviewHeight] = useState<string>('150')
const [isPinning, setIsPinning] = useState(false)
const [configLabel, setConfigLabel] = useState<string>('')
const [activeWidgets, setActiveWidgets] = useState<WidgetInfo[]>([])
const [selectedWidgetInstanceId, setSelectedWidgetInstanceId] = useState<number | null>(null)

const selectedWidget = AVAILABLE_WIDGETS.find((w) => w.id === selectedWidgetId) || AVAILABLE_WIDGETS[0]

const resetPreviewDimensions = (widget = selectedWidget) => {
// Use dimensions that match actual widget content variants for better previews
if (widget.id === 'interactive_todos') {
setPreviewWidth('250')
setPreviewHeight('150')
} else {
setPreviewWidth(String(widget.defaultPreviewWidth))
setPreviewHeight(String(widget.defaultPreviewHeight))
}
}

const refreshActiveWidgets = useCallback(async () => {
if (Platform.OS !== 'android') {
return
}

try {
const widgets = await getActiveWidgets()
setActiveWidgets(widgets)
setSelectedWidgetInstanceId((current) => {
if (widgets.some((widget) => widget.widgetId === current && widget.name === CONFIGURABLE_WIDGET_ID)) {
return current
}

return widgets.find((widget) => widget.name === CONFIGURABLE_WIDGET_ID)?.widgetId ?? null
})
} catch (error: any) {
Alert.alert('Error', error?.message || String(error))
}
}, [])

useEffect(() => {
refreshActiveWidgets()
}, [refreshActiveWidgets])

const handleSetConfig = async () => {
if (Platform.OS !== 'android') {
return
}

if (selectedWidgetInstanceId == null) {
Alert.alert('No widget selected', 'Pin the configurable demo widget first, then choose that instance.')
return
}

const selectedWidget = activeWidgets.find(
(widget) => widget.widgetId === selectedWidgetInstanceId && widget.name === CONFIGURABLE_WIDGET_ID
)
if (!selectedWidget) {
Alert.alert('Unsupported widget', 'Only the client-rendered demo widget is configurable in this example.')
return
}

try {
await setWidgetConfiguration(selectedWidgetId, 'label', configLabel)
Alert.alert('Saved', `Set config "label" = "${configLabel}" for ${selectedWidgetId}. The widget re-renders.`)
await setWidgetInstanceConfiguration(selectedWidgetInstanceId, 'label', configLabel)
Alert.alert(
'Saved',
`Set config "label" = "${configLabel}" for widget instance #${selectedWidgetInstanceId}. The widget re-renders.`
)
await refreshActiveWidgets()
} catch (error: any) {
Alert.alert('Error', error?.message || String(error))
}
}

const selectedWidget = AVAILABLE_WIDGETS.find((w) => w.id === selectedWidgetId) || AVAILABLE_WIDGETS[0]

const handlePinWidget = async (usePreview: boolean) => {
if (Platform.OS !== 'android') {
Alert.alert('Not Available', 'Widget pinning is only available on Android devices.')
Expand All @@ -81,6 +143,7 @@ export default function AndroidWidgetPinScreen() {
'Success',
`Pin request sent for ${selectedWidget.name}! Check your home screen to complete the pinning.`
)
await refreshActiveWidgets()
} else {
Alert.alert(
'Not Supported',
Expand All @@ -96,17 +159,6 @@ export default function AndroidWidgetPinScreen() {
}
}

const resetPreviewDimensions = () => {
// Use dimensions that match actual widget content variants for better previews
if (selectedWidget.id === 'interactive_todos') {
setPreviewWidth('250')
setPreviewHeight('150')
} else {
setPreviewWidth(String(selectedWidget.defaultPreviewWidth))
setPreviewHeight(String(selectedWidget.defaultPreviewHeight))
}
}

return (
<ScreenLayout
title="Pin Widget to Home Screen"
Expand All @@ -121,7 +173,7 @@ export default function AndroidWidgetPinScreen() {
variant={selectedWidgetId === widget.id ? 'primary' : 'secondary'}
onPress={() => {
setSelectedWidgetId(widget.id)
resetPreviewDimensions()
resetPreviewDimensions(widget)
}}
style={styles.widgetButton}
/>
Expand All @@ -131,7 +183,34 @@ export default function AndroidWidgetPinScreen() {
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Configuration (Dynamic Widget)</Text>
<View style={styles.sectionHeaderRow}>
<Text style={styles.sectionTitle}>Configuration (Dynamic Widget)</Text>
<Button title="Refresh" variant="ghost" onPress={refreshActiveWidgets} style={styles.smallButton} />
</View>
<Text style={styles.sectionDescription}>
Configure one placed client-rendered widget instance at a time. This uses the new instance-level API, so two
widgets of the same type can keep different values.
</Text>
<Text style={styles.sectionSubtitle}>Active widget instances</Text>
{activeWidgets.some((widget) => widget.name === CONFIGURABLE_WIDGET_ID) ? (
activeWidgets
.filter((widget) => widget.name === CONFIGURABLE_WIDGET_ID)
.map((widget) => (
<View key={`${widget.widgetId}`} style={styles.widgetInstanceOption}>
<Button
title={`${widget.name} #${widget.widgetId}`}
variant={selectedWidgetInstanceId === widget.widgetId ? 'primary' : 'secondary'}
onPress={() => setSelectedWidgetInstanceId(widget.widgetId)}
style={styles.widgetInstanceButton}
/>
<Text style={styles.widgetInstanceDescription}>
{widget.width}x{widget.height}dp · {widget.providerClassName}
</Text>
</View>
))
) : (
<Text style={styles.emptyState}>Pin the client-rendered demo widget first to configure an instance.</Text>
)}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>env.configuration.label</Text>
<TextInput
Expand All @@ -143,7 +222,7 @@ export default function AndroidWidgetPinScreen() {
autoCapitalize="none"
/>
</View>
<Button title="Set configuration" onPress={handleSetConfig} style={styles.resetButton} />
<Button title="Set instance configuration" onPress={handleSetConfig} style={styles.resetButton} />
</View>

<View style={styles.section}>
Expand Down Expand Up @@ -204,12 +283,30 @@ const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
sectionHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 12,
},
sectionDescription: {
fontSize: 12,
lineHeight: 18,
color: '#94A3B8',
marginBottom: 12,
},
sectionSubtitle: {
fontSize: 13,
fontWeight: '600',
color: '#CBD5E1',
marginBottom: 8,
},
widgetOption: {
marginTop: 12,
},
Expand All @@ -222,6 +319,18 @@ const styles = StyleSheet.create({
marginTop: 4,
marginLeft: 4,
},
widgetInstanceOption: {
marginTop: 12,
},
widgetInstanceButton: {
marginBottom: 4,
},
widgetInstanceDescription: {
fontSize: 12,
color: '#94A3B8',
marginTop: 4,
marginLeft: 4,
},
previewInputs: {
marginTop: 16,
gap: 12,
Expand Down Expand Up @@ -258,8 +367,16 @@ const styles = StyleSheet.create({
loadingText: {
marginTop: 12,
fontSize: 12,
color: '#8232FF',
textAlign: 'center',
color: '#94A3B8',
},
smallButton: {
paddingHorizontal: 14,
paddingVertical: 8,
},
emptyState: {
color: '#94A3B8',
fontSize: 12,
marginBottom: 12,
},
footer: {
marginTop: 24,
Expand Down
Loading
Loading