diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt
index 6eac589..2503b98 100644
--- a/android/src/main/java/voltra/generated/ShortNames.kt
+++ b/android/src/main/java/voltra/generated/ShortNames.kt
@@ -19,7 +19,6 @@ object ShortNames {
"al" to "alignment",
"ar" to "aspectRatio",
"an" to "assetName",
- "ahe" to "autoHideOnEnd",
"bkg" to "background",
"bg" to "backgroundColor",
"bgs" to "backgroundStyle",
diff --git a/data/components.json b/data/components.json
index 47d2529..328a014 100644
--- a/data/components.json
+++ b/data/components.json
@@ -3,7 +3,6 @@
"shortNames": {
"style": "s",
"alignment": "al",
- "autoHideOnEnd": "ahe",
"buttonStyle": "bs",
"colors": "cls",
"cornerRadius": "cr",
@@ -685,11 +684,6 @@
"default": "down",
"description": "Count direction"
},
- "autoHideOnEnd": {
- "type": "boolean",
- "optional": true,
- "description": "Hide timer when complete"
- },
"textStyle": {
"type": "string",
"optional": true,
diff --git a/example/app/testing-grounds/timer.tsx b/example/app/testing-grounds/timer.tsx
new file mode 100644
index 0000000..7fb9b68
--- /dev/null
+++ b/example/app/testing-grounds/timer.tsx
@@ -0,0 +1,3 @@
+import TimerTestingScreen from '../../screens/testing-grounds/timer/TimerTestingScreen'
+
+export default TimerTestingScreen
diff --git a/example/screens/testing-grounds/TestingGroundsScreen.tsx b/example/screens/testing-grounds/TestingGroundsScreen.tsx
index e2f5484..1497135 100644
--- a/example/screens/testing-grounds/TestingGroundsScreen.tsx
+++ b/example/screens/testing-grounds/TestingGroundsScreen.tsx
@@ -13,6 +13,13 @@ const TESTING_GROUNDS_SECTIONS = [
'Test the weather widget with different conditions, gradients, and real-time updates. Change weather conditions and see the widget update instantly.',
route: '/testing-grounds/weather',
},
+ {
+ id: 'timer',
+ title: 'Timer',
+ description:
+ 'Test the VoltraTimer component with different styles (Timer/Relative), count directions, and templates. Verifies native Live Activity behavior.',
+ route: '/testing-grounds/timer',
+ },
{
id: 'styling',
title: 'Styling',
diff --git a/example/screens/testing-grounds/timer/TimerTestingScreen.tsx b/example/screens/testing-grounds/timer/TimerTestingScreen.tsx
new file mode 100644
index 0000000..bac86cd
--- /dev/null
+++ b/example/screens/testing-grounds/timer/TimerTestingScreen.tsx
@@ -0,0 +1,238 @@
+import { useRouter } from 'expo-router'
+import React, { useState } from 'react'
+import { ScrollView, StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native'
+import { Voltra } from 'voltra'
+import { VoltraWidgetPreview } from 'voltra/client'
+
+import { Button } from '~/components/Button'
+import { Card } from '~/components/Card'
+
+const DEFAULT_TEMPLATES = {
+ running: 'Time remaining: {time}',
+ completed: 'Timer finished!',
+}
+
+export default function TimerTestingScreen() {
+ const router = useRouter()
+ const colorScheme = useColorScheme()
+
+ // Timer State
+ const [mode, setMode] = useState<'timer' | 'stopwatch'>('timer')
+ const [direction, setDirection] = useState<'up' | 'down'>('down')
+ const [textStyle, setTextStyle] = useState<'timer' | 'relative'>('timer')
+ const [showHours, setShowHours] = useState(false)
+ const [durationSec, setDurationSec] = useState('300') // 5 minutes default
+ const [templateJson, setTemplateJson] = useState(JSON.stringify(DEFAULT_TEMPLATES, null, 2))
+
+ // Timestamps
+ const [timerState, setTimerState] = useState<{ startAtMs?: number; endAtMs?: number; durationMs?: number }>({
+ endAtMs: Date.now() + 300000,
+ })
+
+ const widgetPreviewStyle = {
+ borderRadius: 16,
+ backgroundColor: colorScheme === 'light' ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)',
+ }
+
+ const resetTimer = () => {
+ const duration = (parseInt(durationSec) || 0) * 1000
+ const now = Date.now()
+
+ if (mode === 'stopwatch') {
+ setTimerState({
+ startAtMs: now,
+ endAtMs: undefined,
+ durationMs: undefined,
+ })
+ setDirection('up')
+ } else {
+ if (direction === 'down') {
+ setTimerState({
+ startAtMs: now,
+ endAtMs: now + duration,
+ durationMs: duration,
+ })
+ } else {
+ setTimerState({
+ startAtMs: now,
+ endAtMs: now + duration,
+ durationMs: duration,
+ })
+ }
+ }
+ }
+
+ // Timer Component for Preview
+ const renderTimerWidget = () => (
+
+
+ Voltra Timer
+
+
+
+ )
+
+ return (
+
+
+ Timer Testing
+
+ Test the VoltraTimer component behaviors, including native text updates for Live Activities.
+
+
+ {/* Preview */}
+
+ Live Preview
+
+
+ {renderTimerWidget()}
+
+
+
+
+
+ {/* Configuration */}
+
+ Configuration
+
+
+ Mode
+
+
+
+
+ {mode === 'timer' && (
+ <>
+
+ Direction
+
+ setDirection('down')}
+ style={styles.smButton}
+ />
+ setDirection('up')}
+ style={styles.smButton}
+ />
+
+
+
+
+ Duration (seconds)
+
+
+ >
+ )}
+
+
+ Style
+
+ setTextStyle('timer')}
+ style={styles.smButton}
+ />
+ setTextStyle('relative')}
+ style={styles.smButton}
+ />
+
+
+
+
+ Show Hours
+
+ setShowHours(false)}
+ style={styles.smButton}
+ />
+ setShowHours(true)}
+ style={styles.smButton}
+ />
+
+
+
+
+ Templates (JSON)
+
+
+
+
+
+ router.back()} />
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ scrollView: { flex: 1 },
+ content: { padding: 20 },
+ heading: { fontSize: 24, fontWeight: '700', color: '#FFFFFF', marginBottom: 8 },
+ subheading: { fontSize: 14, color: '#CBD5F5', marginBottom: 24 },
+ previewContainer: { alignItems: 'center', padding: 10 },
+ row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 },
+ col: { marginBottom: 16 },
+ label: { color: '#fff', fontSize: 16 },
+ input: {
+ backgroundColor: '#rgba(255,255,255,0.1)',
+ color: '#fff',
+ padding: 8,
+ borderRadius: 8,
+ minWidth: 100,
+ textAlign: 'right',
+ },
+ textArea: { textAlign: 'left', height: 100, textAlignVertical: 'top' },
+ toggleGroup: { flexDirection: 'row', gap: 8 },
+ smButton: { paddingVertical: 6, paddingHorizontal: 12 },
+ footer: { marginTop: 24, alignItems: 'center' },
+})
diff --git a/ios/shared/ShortNames.swift b/ios/shared/ShortNames.swift
index 762c0b6..74e5567 100644
--- a/ios/shared/ShortNames.swift
+++ b/ios/shared/ShortNames.swift
@@ -16,7 +16,6 @@ public enum ShortNames {
"al": "alignment",
"ar": "aspectRatio",
"an": "assetName",
- "ahe": "autoHideOnEnd",
"bkg": "background",
"bg": "backgroundColor",
"bgs": "backgroundStyle",
diff --git a/ios/ui/Generated/Parameters/TimerParameters.swift b/ios/ui/Generated/Parameters/TimerParameters.swift
index 90ca95f..1842553 100644
--- a/ios/ui/Generated/Parameters/TimerParameters.swift
+++ b/ios/ui/Generated/Parameters/TimerParameters.swift
@@ -22,9 +22,6 @@ public struct TimerParameters: ComponentParameters {
/// Count direction
public let direction: String
- /// Hide timer when complete
- public let autoHideOnEnd: Bool?
-
/// Text formatting style
public let textStyle: String
@@ -39,7 +36,6 @@ public struct TimerParameters: ComponentParameters {
case startAtMs
case durationMs
case direction
- case autoHideOnEnd
case textStyle
case textTemplates
case showHours
@@ -51,7 +47,6 @@ public struct TimerParameters: ComponentParameters {
startAtMs = try container.decodeIfPresent(Double.self, forKey: .startAtMs)
durationMs = try container.decodeIfPresent(Double.self, forKey: .durationMs)
direction = try container.decodeIfPresent(String.self, forKey: .direction) ?? "down"
- autoHideOnEnd = try container.decodeIfPresent(Bool.self, forKey: .autoHideOnEnd)
textStyle = try container.decodeIfPresent(String.self, forKey: .textStyle) ?? "timer"
textTemplates = try container.decodeIfPresent(String.self, forKey: .textTemplates)
showHours = try container.decodeIfPresent(Bool.self, forKey: .showHours) ?? false
diff --git a/ios/ui/Views/VoltraTimer.swift b/ios/ui/Views/VoltraTimer.swift
index 613cc55..27b7903 100644
--- a/ios/ui/Views/VoltraTimer.swift
+++ b/ios/ui/Views/VoltraTimer.swift
@@ -10,15 +10,26 @@ public struct VoltraTimer: VoltraView {
}
private func progressRange(params: TimerParameters) -> ClosedRange? {
- VoltraProgressDriver.resolveRange(
+ // Stopwatch support: if counting up (not down) and we have a start time
+ // but no end time or duration, treat it as an open-ended stopwatch.
+ if !countsDown(params: params),
+ let startAtMs = params.startAtMs,
+ params.endAtMs == nil,
+ params.durationMs == nil
+ {
+ let start = Date(timeIntervalSince1970: startAtMs / 1000)
+ return start ... Date.distantFuture
+ }
+
+ return VoltraProgressDriver.resolveRange(
startAtMs: params.startAtMs,
endAtMs: params.endAtMs,
durationMs: params.durationMs
)
}
- private func resolvedStartDate(params: TimerParameters) -> Date? { progressRange(params: params)?.lowerBound }
private func resolvedEndDate(params: TimerParameters) -> Date? { progressRange(params: params)?.upperBound }
+
private func countsDown(params: TimerParameters) -> Bool {
(params.direction.lowercased()) != "up"
}
@@ -27,10 +38,6 @@ public struct VoltraTimer: VoltraView {
params.textStyle.lowercased()
}
- private func autoHideOnEnd(params: TimerParameters) -> Bool {
- params.autoHideOnEnd ?? false
- }
-
private struct TextTemplates: Codable {
let running: String?
let completed: String?
@@ -42,122 +49,78 @@ public struct VoltraTimer: VoltraView {
return try? JSONDecoder().decode(TextTemplates.self, from: data)
}
- private func countdownTextView(params: TimerParameters, range: ClosedRange) -> some View {
- let style = textStyle(params: params)
- let templates = textTemplates(params: params)
- let showHours = params.showHours
- return TimelineView(.animation) { context in
- let remaining = range.upperBound.timeIntervalSince(context.date)
- if let text = formattedCountdownText(remaining: remaining, templates: templates, textStyle: style, showHours: showHours) {
- text
- } else if remaining > 0 {
- if style == "relative" {
- Text(range.upperBound, style: .relative)
- } else {
- Text(timerInterval: context.date ... range.upperBound, countsDown: true, showsHours: showHours)
- .monospacedDigit()
- }
- } else {
- if style == "relative" {
- Text("0s")
- } else {
- Text(showHours ? "0:00:00" : "0:00").monospacedDigit()
- }
- }
+ @ViewBuilder
+ public var body: some View {
+ if let range = progressRange(params: params) {
+ timerView(params: params, range: range)
+ .applyStyle(element.style)
}
}
- private func countUpTextView(params: TimerParameters, range: ClosedRange) -> some View {
- let style = textStyle(params: params)
+ @ViewBuilder
+ private func timerView(params: TimerParameters, range: ClosedRange) -> some View {
+ let isFinished = Date() >= range.upperBound
let templates = textTemplates(params: params)
+ let style = textStyle(params: params)
let showHours = params.showHours
- return TimelineView(.animation) { context in
- let elapsedRaw = context.date.timeIntervalSince(range.lowerBound)
- let total = range.upperBound.timeIntervalSince(range.lowerBound)
- let elapsed = total > 0 ? min(max(0, elapsedRaw), total) : max(0, elapsedRaw)
- if let text = formattedCountUpText(elapsed: elapsed, templates: templates, textStyle: style, showHours: showHours) {
- text
- } else if style == "relative" {
- Text(range.lowerBound, style: .relative)
- } else {
- Text(timerInterval: range.lowerBound ... context.date, countsDown: false, showsHours: showHours)
- .monospacedDigit()
- }
- }
- }
- private func formattedCountdownText(remaining: TimeInterval, templates: TextTemplates?, textStyle: String, showHours: Bool) -> Text? {
- guard let templates = templates else { return nil }
- let monospaced = textStyle != "relative"
- if remaining > 0 {
- if let template = templates.running {
- let formatted = countdownTimeString(remaining: remaining, textStyle: textStyle, showHours: showHours)
- return renderTemplate(template: template, time: formatted, monospaced: monospaced)
+ if isFinished, let completedTemplate = templates?.completed {
+ renderTemplate(template: completedTemplate) {
+ staticZeroText(style: style, showHours: showHours)
}
} else {
- if let template = templates.completed {
- let formatted = countdownTimeString(remaining: 0, textStyle: textStyle, showHours: showHours)
- return renderTemplate(template: template, time: formatted, monospaced: monospaced)
+ if let runningTemplate = templates?.running {
+ renderTemplate(template: runningTemplate) {
+ activeTimerText(params: params, range: range)
+ }
+ } else {
+ activeTimerText(params: params, range: range)
}
}
- return nil
}
- private func formattedCountUpText(elapsed: TimeInterval, templates: TextTemplates?, textStyle: String, showHours: Bool) -> Text? {
- guard let template = templates?.running else { return nil }
- let formatted = countUpTimeString(elapsed: elapsed, textStyle: textStyle, showHours: showHours)
- let monospaced = textStyle != "relative"
- return renderTemplate(template: template, time: formatted, monospaced: monospaced)
- }
+ @ViewBuilder
+ private func activeTimerText(params: TimerParameters, range: ClosedRange) -> some View {
+ let style = textStyle(params: params)
+ let isCountDown = countsDown(params: params)
+ let showHours = params.showHours
- private func countdownTimeString(remaining: TimeInterval, textStyle: String, showHours: Bool) -> String {
- if textStyle == "relative" {
- if remaining <= 0 { return "0s" }
- return Self.relativeFormatter.localizedString(fromTimeInterval: remaining)
+ if style == "relative" {
+ let targetDate = isCountDown ? range.upperBound : range.lowerBound
+ Text(targetDate, style: .relative)
+ } else {
+ // Live Activities require Text(timerInterval:...) for automatic updates
+ Text(timerInterval: range, countsDown: isCountDown, showsHours: showHours)
+ .monospacedDigit()
}
- return Self.timerFormatter(showHours: showHours).string(from: max(remaining, 0)) ?? (showHours ? "0:00:00" : "0:00")
}
- private func countUpTimeString(elapsed: TimeInterval, textStyle: String, showHours: Bool) -> String {
- if textStyle == "relative" {
- return Self.relativeFormatter.localizedString(fromTimeInterval: -elapsed)
+ @ViewBuilder
+ private func staticZeroText(style: String, showHours: Bool) -> some View {
+ if style == "relative" {
+ Text("0s")
+ } else {
+ Text(showHours ? "0:00:00" : "0:00")
+ .monospacedDigit()
}
- return Self.timerFormatter(showHours: showHours).string(from: max(elapsed, 0)) ?? (showHours ? "0:00:00" : "0:00")
}
- private func renderTemplate(template: String, time: String, monospaced: Bool) -> Text {
+ @ViewBuilder
+ private func renderTemplate(template: String, @ViewBuilder timeView: @escaping () -> T) -> some View {
let placeholder = "{time}"
let segments = template.components(separatedBy: placeholder)
- guard segments.count > 1 else {
- return Text(template)
- }
- var text = Text(verbatim: segments.first ?? "")
- for segment in segments.dropFirst() {
- let timeText = Text(time)
- let formattedTime = monospaced ? timeText.monospacedDigit() : timeText
- text = text + formattedTime
- if !segment.isEmpty {
- text = text + Text(verbatim: segment)
- }
- }
-
- return text
- }
-
- @ViewBuilder
- public var body: some View {
- if let range = progressRange(params: params) {
- if !autoHideOnEnd(params: params) || resolvedEndDate(params: params).map({ Date() < $0 }) ?? true {
- // Timer component now only supports text mode
- if countsDown(params: params) {
- countdownTextView(params: params, range: range)
- .applyStyle(element.style)
- } else {
- countUpTextView(params: params, range: range)
- .applyStyle(element.style)
+ if segments.count > 1 {
+ HStack(spacing: 0) {
+ ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in
+ Text(verbatim: segment)
+ if index < segments.count - 1 {
+ timeView()
+ }
}
}
+ } else {
+ Text(template)
}
}
}
@@ -165,29 +128,5 @@ public struct VoltraTimer: VoltraView {
// MARK: - Formatters
extension VoltraTimer {
- private static let timerFormatterWithHours: DateComponentsFormatter = {
- let formatter = DateComponentsFormatter()
- formatter.allowedUnits = [.hour, .minute, .second]
- formatter.zeroFormattingBehavior = [.pad]
- formatter.unitsStyle = .positional
- return formatter
- }()
-
- private static let timerFormatterWithoutHours: DateComponentsFormatter = {
- let formatter = DateComponentsFormatter()
- formatter.allowedUnits = [.minute, .second]
- formatter.zeroFormattingBehavior = [.pad]
- formatter.unitsStyle = .positional
- return formatter
- }()
-
- private static func timerFormatter(showHours: Bool) -> DateComponentsFormatter {
- showHours ? timerFormatterWithHours : timerFormatterWithoutHours
- }
-
- private static let relativeFormatter: RelativeDateTimeFormatter = {
- let formatter = RelativeDateTimeFormatter()
- formatter.unitsStyle = .short
- return formatter
- }()
+ // Formatters are no longer needed for live updates but kept if we need static fallbacks.
}
diff --git a/src/jsx/props/Timer.ts b/src/jsx/props/Timer.ts
index 42f1282..83ff8f9 100644
--- a/src/jsx/props/Timer.ts
+++ b/src/jsx/props/Timer.ts
@@ -13,8 +13,6 @@ export type TimerProps = VoltraBaseProps & {
durationMs?: number
/** Count direction */
direction?: 'up' | 'down'
- /** Hide timer when complete */
- autoHideOnEnd?: boolean
/** Text formatting style */
textStyle?: 'timer' | 'relative'
/** JSON-encoded TextTemplates object with running/completed templates */
diff --git a/src/payload/short-names.ts b/src/payload/short-names.ts
index a992747..c47494f 100644
--- a/src/payload/short-names.ts
+++ b/src/payload/short-names.ts
@@ -12,7 +12,6 @@ export const NAME_TO_SHORT: Record = {
alignment: 'al',
aspectRatio: 'ar',
assetName: 'an',
- autoHideOnEnd: 'ahe',
background: 'bkg',
backgroundColor: 'bg',
backgroundStyle: 'bgs',
@@ -159,7 +158,6 @@ export const SHORT_TO_NAME: Record = {
al: 'alignment',
ar: 'aspectRatio',
an: 'assetName',
- ahe: 'autoHideOnEnd',
bkg: 'background',
bg: 'backgroundColor',
bgs: 'backgroundStyle',
diff --git a/website/docs/ios/components/status.md b/website/docs/ios/components/status.md
index 6818c4d..3eb64a7 100644
--- a/website/docs/ios/components/status.md
+++ b/website/docs/ios/components/status.md
@@ -22,4 +22,47 @@ A gauge indicator for progress visualization (iOS 16+).
### Timer
-A flexible countdown or stopwatch component. This is crucial for Live Activities.
+A flexible component for displaying live-updating time intervals. Crucial for Live Activities, it uses native SwiftUI text interpolation to ensure the time updates automatically on the lock screen and in the Dynamic Island without requiring background updates from React Native.
+
+**Modes:**
+
+- **Timer Mode:** For fixed intervals (countdowns or counting up to a target). Requires `endAtMs` or `durationMs`.
+- **Stopwatch Mode:** For open-ended intervals counting up from a starting point. Requires `startAtMs` and `direction="up"`, but both `endAtMs` and `durationMs` must be omitted.
+
+**Parameters:**
+
+- `startAtMs` (number, optional): Start time in milliseconds since epoch.
+- `endAtMs` (number, optional): End time in milliseconds since epoch.
+- `durationMs` (number, optional): Duration in milliseconds. Used if `endAtMs` is omitted.
+- `direction` (string, optional): Count direction. Can be `'up'` or `'down'`. Defaults to `'down'`.
+- `textStyle` (string, optional): Formatting style.
+ - `'timer'`: Standard clock format (e.g., `05:00`).
+ - `'relative'`: Relative format (e.g., `5m`).
+- `showHours` (boolean, optional): Whether to show hours (e.g., `1:30:00` vs `90:00`). Defaults to `false`.
+- `textTemplates` (string, optional): JSON-encoded object with `running` and `completed` templates. Use `{time}` as a placeholder.
+
+**Examples:**
+
+```tsx
+// Timer Mode: Countdown 5 minutes
+
+
+// Stopwatch Mode: Count up indefinitely from now
+
+
+// Relative Timer with Template
+
+```