Files
timetracker/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift
Simon Franken 461405c418 Fix iOS timer widget not displaying active timers
The widget was failing to decode the cached timer data because WidgetTimer
struct didn't match the OngoingTimer JSON structure saved by the app.

Changes:
- Added missing fields (project, createdAt, updatedAt) to WidgetTimer
- Added WidgetProjectReference struct for nested project data
- Fixed project name to use project.name instead of projectId
- Added project color support
- Increased refresh interval from 15 min to 1 min for live updates
2026-02-20 15:49:35 +01:00

205 lines
6.1 KiB
Swift

import WidgetKit
import SwiftUI
struct TimerEntry: TimelineEntry {
let date: Date
let timer: WidgetTimer?
let projectName: String?
let projectColor: String?
}
struct WidgetTimer: Codable {
let id: String
let startTime: String
let projectId: String?
let project: WidgetProjectReference?
let createdAt: String
let updatedAt: String
var elapsedTime: TimeInterval {
guard let start = ISO8601DateFormatter().date(from: startTime) else {
return 0
}
return Date().timeIntervalSince(start)
}
}
struct WidgetProjectReference: Codable {
let id: String
let name: String
let color: String?
}
struct Provider: TimelineProvider {
private let appGroupIdentifier = "group.com.timetracker.app"
func placeholder(in context: Context) -> TimerEntry {
TimerEntry(
date: Date(),
timer: nil,
projectName: nil,
projectColor: nil
)
}
func getSnapshot(in context: Context, completion: @escaping (TimerEntry) -> Void) {
let entry = loadTimerEntry()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<TimerEntry>) -> Void) {
let entry = loadTimerEntry()
// Update every minute to show live timer countdown
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 1, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func loadTimerEntry() -> TimerEntry {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
let data = userDefaults.data(forKey: "cachedTimer") else {
return TimerEntry(
date: Date(),
timer: nil,
projectName: nil,
projectColor: nil
)
}
do {
let timer = try JSONDecoder().decode(WidgetTimer.self, from: data)
return TimerEntry(
date: Date(),
timer: timer,
projectName: timer.project?.name ?? timer.projectId,
projectColor: timer.project?.color
)
} catch {
return TimerEntry(
date: Date(),
timer: nil,
projectName: nil,
projectColor: nil
)
}
}
}
struct TimerWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
smallWidget
case .systemMedium:
mediumWidget
default:
smallWidget
}
}
private var smallWidget: some View {
VStack(spacing: 8) {
if let timer = entry.timer {
Image(systemName: "timer")
.font(.title2)
.foregroundStyle(.green)
Text(timer.elapsedTime.formattedDuration)
.font(.system(size: 24, weight: .medium, design: .monospaced))
if let projectId = entry.projectName {
Text(projectId)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} else {
Image(systemName: "timer")
.font(.title2)
.foregroundStyle(.secondary)
Text("No timer")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
private var mediumWidget: some View {
HStack(spacing: 16) {
if let timer = entry.timer {
VStack(spacing: 4) {
Image(systemName: "timer")
.font(.title)
.foregroundStyle(.green)
Text(timer.elapsedTime.formattedDuration)
.font(.system(size: 28, weight: .medium, design: .monospaced))
}
VStack(alignment: .leading, spacing: 4) {
if let projectId = entry.projectName {
Text(projectId)
.font(.headline)
}
Text("Tap to open")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
VStack(spacing: 8) {
Image(systemName: "timer")
.font(.title)
.foregroundStyle(.secondary)
Text("No active timer")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
}
extension TimeInterval {
var formattedDuration: String {
let hours = Int(self) / 3600
let minutes = (Int(self) % 3600) / 60
let seconds = Int(self) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%02d:%02d", minutes, seconds)
}
}
}
struct TimeTrackerWidget: Widget {
let kind: String = "TimeTrackerWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TimerWidgetEntryView(entry: entry)
}
.configurationDisplayName("Timer")
.description("Shows your active timer.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}