diff --git a/example/app/testing-grounds/widget-scheduling.tsx b/example/app/testing-grounds/widget-scheduling.tsx
new file mode 100644
index 00000000..17ba502c
--- /dev/null
+++ b/example/app/testing-grounds/widget-scheduling.tsx
@@ -0,0 +1,3 @@
+import WidgetSchedulingScreen from '~/screens/testing-grounds/WidgetSchedulingScreen'
+
+export default WidgetSchedulingScreen
diff --git a/example/screens/testing-grounds/TestingGroundsScreen.tsx b/example/screens/testing-grounds/TestingGroundsScreen.tsx
index c5460978..3c38ff7a 100644
--- a/example/screens/testing-grounds/TestingGroundsScreen.tsx
+++ b/example/screens/testing-grounds/TestingGroundsScreen.tsx
@@ -55,6 +55,13 @@ const TESTING_GROUNDS_SECTIONS = [
'Test image preloading functionality for Live Activities. Download images to App Group storage and verify they appear in Live Activities.',
route: '/testing-grounds/image-preloading',
},
+ {
+ id: 'widget-scheduling',
+ title: 'Widget Scheduling',
+ description:
+ 'Test widget timeline scheduling with multiple states. Configure timing for each state and watch widgets automatically transition between them.',
+ route: '/testing-grounds/widget-scheduling',
+ },
// Add more sections here as they are implemented
]
diff --git a/example/screens/testing-grounds/WidgetSchedulingScreen.tsx b/example/screens/testing-grounds/WidgetSchedulingScreen.tsx
new file mode 100644
index 00000000..493fdb96
--- /dev/null
+++ b/example/screens/testing-grounds/WidgetSchedulingScreen.tsx
@@ -0,0 +1,448 @@
+import { useRouter } from 'expo-router'
+import React, { useState } from 'react'
+import { Alert, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native'
+import { Voltra } from 'voltra'
+import { reloadWidgets, scheduleWidget, VoltraWidgetPreview } from 'voltra/client'
+
+import { Button } from '~/components/Button'
+import { Card } from '~/components/Card'
+
+export default function WidgetSchedulingScreen() {
+ const colorScheme = useColorScheme()
+ const router = useRouter()
+ const [isScheduling, setIsScheduling] = useState(false)
+ const [minutesUntilSecond, setMinutesUntilSecond] = useState('2')
+ const [minutesUntilThird, setMinutesUntilThird] = useState('5')
+ const [scheduledTimes, setScheduledTimes] = useState<{ past: string; second: string; third: string } | null>(null)
+
+ const widgetPreviewStyle = {
+ borderRadius: 16,
+ backgroundColor: colorScheme === 'light' ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)',
+ }
+
+ const handleScheduleTimeline = async () => {
+ setIsScheduling(true)
+ try {
+ const now = new Date()
+ const secondMinutes = parseInt(minutesUntilSecond) || 2
+ const thirdMinutes = parseInt(minutesUntilThird) || 5
+
+ // Entry 1: Yesterday (past entry - shows as current state)
+ const yesterday = new Date(now)
+ yesterday.setDate(yesterday.getDate() - 1)
+ yesterday.setHours(12, 0, 0, 0)
+
+ // Entry 2: Future entry (configured minutes from now)
+ const secondEntry = new Date(now.getTime() + secondMinutes * 60 * 1000)
+
+ // Entry 3: Future entry (configured minutes from now)
+ const thirdEntry = new Date(now.getTime() + thirdMinutes * 60 * 1000)
+
+ const entries = [
+ {
+ date: yesterday,
+ variants: {
+ systemSmall: (
+
+
+ STATE 1
+ Current
+ Yesterday
+
+
+ ),
+ systemMedium: (
+
+
+ STATE 1
+ Current State
+ Scheduled: Yesterday
+
+
+ ),
+ systemLarge: (
+
+
+ STATE 1
+ Current State
+ Scheduled: Yesterday at Noon
+
+
+ ),
+ },
+ },
+ {
+ date: secondEntry,
+ variants: {
+ systemSmall: (
+
+
+ STATE 2
+ +{secondMinutes} min
+
+
+ ),
+ systemMedium: (
+
+
+ STATE 2
+ Second State
+
+ {secondEntry.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
+
+
+ ),
+ systemLarge: (
+
+
+ STATE 2
+ Second State
+
+ {secondEntry.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
+
+
+ ),
+ },
+ },
+ {
+ date: thirdEntry,
+ variants: {
+ systemSmall: (
+
+
+ STATE 3
+ +{thirdMinutes} min
+
+
+ ),
+ systemMedium: (
+
+
+ STATE 3
+ Third State
+
+ {thirdEntry.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
+
+
+ ),
+ systemLarge: (
+
+
+ STATE 3
+ Third State
+
+ {thirdEntry.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
+
+
+ ),
+ },
+ },
+ ]
+
+ await scheduleWidget('weather', entries)
+ await reloadWidgets(['weather'])
+
+ // Format times for display
+ const formatter = new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+
+ setScheduledTimes({
+ past: formatter.format(yesterday),
+ second: formatter.format(secondEntry),
+ third: formatter.format(thirdEntry),
+ })
+
+ Alert.alert(
+ 'Timeline Scheduled',
+ `Three states scheduled:\n\n` +
+ `State 1: ${formatter.format(yesterday)}\n` +
+ `State 2: ${formatter.format(secondEntry)} (+${secondMinutes}m)\n` +
+ `State 3: ${formatter.format(thirdEntry)} (+${thirdMinutes}m)\n\n` +
+ `Watch the widget transition between states!`,
+ [{ text: 'OK' }]
+ )
+ } catch (error) {
+ console.error('Failed to schedule timeline:', error)
+ Alert.alert('Error', 'Failed to schedule timeline. Check console for details.')
+ } finally {
+ setIsScheduling(false)
+ }
+ }
+
+ const handleClearTimeline = async () => {
+ try {
+ // Clear by updating with current content
+ await scheduleWidget('weather', [])
+ await reloadWidgets(['weather'])
+ setScheduledTimes(null)
+ Alert.alert('Timeline Cleared', 'The widget timeline has been cleared.')
+ } catch (error) {
+ console.error('Failed to clear timeline:', error)
+ Alert.alert('Error', 'Failed to clear timeline. Check console for details.')
+ }
+ }
+
+ return (
+
+
+ Widget Scheduling
+
+ Test widget timeline scheduling with multiple states. Configure when each state should appear and watch the
+ widget transition automatically.
+
+
+ {/* Configuration */}
+
+ ⚙️ Configuration
+ Set when each future state should appear:
+
+
+ State 2 (minutes from now):
+
+
+
+
+ State 3 (minutes from now):
+
+
+
+
+ {/* Schedule Timeline */}
+
+ 📅 Schedule Timeline
+
+ Schedules three widget states:{'\n\n'}
+ • State 1 (Blue): Yesterday - shows as current{'\n'}
+ • State 2 (Green): {minutesUntilSecond || '2'} minutes from now{'\n'}
+ • State 3 (Purple): {minutesUntilThird || '5'} minutes from now{'\n\n'}
+ Add the widget to your home screen to see it transition between states.
+
+
+
+ {scheduledTimes && (
+
+ )}
+
+
+
+ {/* Scheduled Times */}
+ {scheduledTimes && (
+
+ ⏰ Scheduled Times
+
+
+
+
+ State 1 (Current)
+ {scheduledTimes.past}
+
+
+
+
+
+ State 2
+ {scheduledTimes.second}
+
+
+
+
+
+ State 3
+ {scheduledTimes.third}
+
+
+
+
+ )}
+
+ {/* Previews */}
+
+ Widget Previews
+
+ State 1 (Current) - Blue
+
+
+
+
+ STATE 1
+ Current State
+ Scheduled: Yesterday
+
+
+
+
+
+ State 2 - Green
+
+
+
+
+ STATE 2
+ Second State
+ +{minutesUntilSecond || '2'} minutes
+
+
+
+
+
+ State 3 - Purple
+
+
+
+
+ STATE 3
+ Third State
+ +{minutesUntilThird || '5'} minutes
+
+
+
+
+
+
+ {/* How to Test */}
+
+ 📝 How to Test
+
+ 1. Configure the timing for states 2 and 3 above{'\n'}
+ 2. Click Schedule Timeline{'\n'}
+ 3. Add the Weather widget to your home screen{'\n'}
+ 4. Verify it shows State 1 (blue background){'\n'}
+ 5. Wait for the scheduled times{'\n'}
+ 6. Watch the widget automatically transition:{'\n'}
+ {' '}• State 1 (Blue) → State 2 (Green) → State 3 (Purple){'\n\n'}
+ Note: iOS may delay widget updates based on battery level, widget
+ visibility, and system load. For immediate updates during testing, keep Xcode attached or use shorter time
+ intervals.
+
+
+
+ {/* Back Button */}
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingVertical: 24,
+ },
+ heading: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ marginBottom: 8,
+ },
+ subheading: {
+ fontSize: 14,
+ lineHeight: 20,
+ color: '#CBD5F5',
+ marginBottom: 24,
+ },
+ bold: {
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ configRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginTop: 12,
+ },
+ configLabel: {
+ fontSize: 16,
+ color: '#FFFFFF',
+ flex: 1,
+ },
+ input: {
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ color: '#FFFFFF',
+ padding: 8,
+ borderRadius: 8,
+ minWidth: 80,
+ textAlign: 'right',
+ fontSize: 16,
+ },
+ buttonGroup: {
+ flexDirection: 'row',
+ gap: 12,
+ marginTop: 16,
+ },
+ timelineInfo: {
+ marginTop: 12,
+ gap: 16,
+ },
+ timelineEntry: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ },
+ statusDot: {
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ },
+ timelineText: {
+ flex: 1,
+ },
+ timelineLabel: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ marginBottom: 2,
+ },
+ timelineTime: {
+ fontSize: 14,
+ color: '#CBD5F5',
+ },
+ previewLabel: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ marginTop: 20,
+ marginBottom: 8,
+ },
+ previewContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 12,
+ },
+ footer: {
+ marginTop: 24,
+ alignItems: 'center',
+ },
+})
diff --git a/ios/target/VoltraHomeWidget.swift b/ios/target/VoltraHomeWidget.swift
index 739125d8..6f15ea9f 100644
--- a/ios/target/VoltraHomeWidget.swift
+++ b/ios/target/VoltraHomeWidget.swift
@@ -72,10 +72,20 @@ public enum VoltraHomeWidgetStore {
}
let now = Date()
- let validEntries = entriesJson.filter { entry in
- guard let timestamp = entry["date"] as? NSNumber else { return false }
+ let parsedEntries: [(date: Date, json: [String: Any])] = entriesJson.compactMap { entry in
+ guard let timestamp = entry["date"] as? NSNumber else { return nil }
let date = Date(timeIntervalSince1970: timestamp.doubleValue / 1000.0)
- return date > now
+ return (date: date, json: entry)
+ }
+
+ // Keep all future entries, plus the most recent past entry (current state).
+ let pastEntries = parsedEntries.filter { $0.date <= now }.sorted { $0.date < $1.date }
+ let latestPastEntry = pastEntries.last?.json
+ let futureEntries = parsedEntries.filter { $0.date > now }.map { $0.json }
+
+ var validEntries = futureEntries
+ if let latestPastEntry {
+ validEntries.insert(latestPastEntry, at: 0)
}
let prunedCount = entriesJson.count - validEntries.count