217 lines
7.8 KiB
Swift
217 lines
7.8 KiB
Swift
// ── Timer List Widget (Medium) ────────────────────────────────
|
|
// Home screen medium widget showing next 3 timers as mini-timeline
|
|
|
|
import SwiftUI
|
|
import WidgetKit
|
|
import AppIntents
|
|
|
|
// MARK: - Timeline Provider
|
|
|
|
struct TimerListProvider: AppIntentTimelineProvider {
|
|
typealias Entry = TimerListEntry
|
|
typealias Intent = TimerListIntent
|
|
|
|
func placeholder(in context: Context) -> TimerListEntry {
|
|
TimerListEntry(date: Date(), timers: Self.placeholderTimers)
|
|
}
|
|
|
|
func snapshot(for configuration: TimerListIntent, in context: Context) async -> TimerListEntry {
|
|
let timers = SharedTimerDataManager.shared.readActiveSnapshots()
|
|
return TimerListEntry(date: Date(), timers: Array(timers.prefix(3)))
|
|
}
|
|
|
|
func timeline(for configuration: TimerListIntent, in context: Context) async -> Timeline<TimerListEntry> {
|
|
let timers = Array(SharedTimerDataManager.shared.readActiveSnapshots().prefix(3))
|
|
let now = Date()
|
|
|
|
var entries: [TimerListEntry] = []
|
|
for minuteOffset in stride(from: 0, through: 30, by: 5) {
|
|
let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: now)!
|
|
entries.append(TimerListEntry(date: entryDate, timers: timers))
|
|
}
|
|
|
|
let reloadDate: Date
|
|
if let firstTimer = timers.first {
|
|
reloadDate = min(firstTimer.targetTime, now.addingTimeInterval(30 * 60))
|
|
} else {
|
|
reloadDate = now.addingTimeInterval(30 * 60)
|
|
}
|
|
|
|
return Timeline(entries: entries, policy: .after(reloadDate))
|
|
}
|
|
|
|
static let placeholderTimers: [TimerSnapshot] = [
|
|
TimerSnapshot(
|
|
id: "p1", label: "Team Standup", type: .alarm, urgency: .important, state: .active,
|
|
targetTime: Date().addingTimeInterval(3600), duration: 3600, startedAt: Date(),
|
|
elapsedBeforePause: 0, snoozeCount: 0, category: nil,
|
|
pomodoroCurrentRound: nil, pomodoroTotalRounds: nil, pomodoroIsBreak: nil,
|
|
nextWarningTime: Date().addingTimeInterval(1800), totalWarnings: 3, firedWarnings: 1
|
|
),
|
|
TimerSnapshot(
|
|
id: "p2", label: "Dentist", type: .alarm, urgency: .standard, state: .active,
|
|
targetTime: Date().addingTimeInterval(7200), duration: 7200, startedAt: Date(),
|
|
elapsedBeforePause: 0, snoozeCount: 0, category: nil,
|
|
pomodoroCurrentRound: nil, pomodoroTotalRounds: nil, pomodoroIsBreak: nil,
|
|
nextWarningTime: nil, totalWarnings: 2, firedWarnings: 0
|
|
),
|
|
TimerSnapshot(
|
|
id: "p3", label: "Pick up laundry", type: .countdown, urgency: .gentle, state: .active,
|
|
targetTime: Date().addingTimeInterval(10800), duration: 10800, startedAt: Date(),
|
|
elapsedBeforePause: 0, snoozeCount: 0, category: nil,
|
|
pomodoroCurrentRound: nil, pomodoroTotalRounds: nil, pomodoroIsBreak: nil,
|
|
nextWarningTime: nil, totalWarnings: 1, firedWarnings: 0
|
|
),
|
|
]
|
|
}
|
|
|
|
// MARK: - Intent
|
|
|
|
struct TimerListIntent: WidgetConfigurationIntent {
|
|
static var title: LocalizedStringResource = "Timer List"
|
|
static var description: IntentDescription = "Shows your next 3 upcoming timers"
|
|
}
|
|
|
|
// MARK: - Entry
|
|
|
|
struct TimerListEntry: TimelineEntry {
|
|
let date: Date
|
|
let timers: [TimerSnapshot]
|
|
}
|
|
|
|
// MARK: - Widget View
|
|
|
|
struct TimerListWidgetView: View {
|
|
let entry: TimerListEntry
|
|
|
|
var body: some View {
|
|
if entry.timers.isEmpty {
|
|
emptyContent
|
|
} else {
|
|
timerListContent
|
|
}
|
|
}
|
|
|
|
private var timerListContent: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
Image(systemName: "clock.fill")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
Text("UPCOMING")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.tracking(0.5)
|
|
Spacer()
|
|
Text("\(entry.timers.count) timer\(entry.timers.count == 1 ? "" : "s")")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.bottom, 6)
|
|
|
|
// Timer rows
|
|
ForEach(Array(entry.timers.enumerated()), id: \.element.id) { index, timer in
|
|
if index > 0 {
|
|
Divider()
|
|
.padding(.vertical, 2)
|
|
}
|
|
timerRow(timer)
|
|
}
|
|
|
|
if entry.timers.count < 3 {
|
|
Spacer()
|
|
}
|
|
}
|
|
.padding()
|
|
.containerBackground(for: .widget) {
|
|
Color(.systemBackground)
|
|
}
|
|
}
|
|
|
|
private func timerRow(_ timer: TimerSnapshot) -> some View {
|
|
HStack(spacing: 8) {
|
|
// Urgency bar
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(urgencyColor(timer.urgency))
|
|
.frame(width: 3, height: 32)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(timer.label)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
.privacySensitive()
|
|
Text(formatTime(timer.targetTime))
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Countdown
|
|
if timer.state == .paused {
|
|
Text("Paused")
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text(timer.targetTime, style: .timer)
|
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(urgencyColor(timer.urgency))
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(minWidth: 50, alignment: .trailing)
|
|
.privacySensitive()
|
|
}
|
|
}
|
|
.widgetURL(URL(string: "chronomind://timer/\(timer.id)"))
|
|
}
|
|
|
|
private var emptyContent: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "clock")
|
|
.font(.system(size: 32))
|
|
.foregroundStyle(.secondary)
|
|
Text("No Active Timers")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
Text("Tap to create one")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.containerBackground(for: .widget) {
|
|
Color(.systemBackground)
|
|
}
|
|
.widgetURL(URL(string: "chronomind://create"))
|
|
}
|
|
|
|
private func urgencyColor(_ urgency: UrgencyLevel) -> Color {
|
|
switch urgency {
|
|
case .critical: return Color(red: 1.0, green: 0.28, blue: 0.34)
|
|
case .important: return Color(red: 1.0, green: 0.62, blue: 0.26)
|
|
case .standard: return Color(red: 0.99, green: 0.79, blue: 0.34)
|
|
case .gentle: return Color(red: 0.18, green: 0.84, blue: 0.45)
|
|
case .passive: return Color(red: 0.36, green: 0.55, blue: 0.93)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget Definition
|
|
|
|
struct TimerListWidget: Widget {
|
|
let kind: String = "TimerListWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
AppIntentConfiguration(
|
|
kind: kind,
|
|
intent: TimerListIntent.self,
|
|
provider: TimerListProvider()
|
|
) { entry in
|
|
TimerListWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName("Timer List")
|
|
.description("Shows your next 3 upcoming timers in a mini-timeline")
|
|
.supportedFamilies([.systemMedium])
|
|
}
|
|
}
|