feat(widget): add WidgetKit widgets — small, medium, lock screen with AppIntentTimelineProvider
This commit is contained in:
parent
4b1cbcf81b
commit
5936016a36
15
ios/ChronoMindWidgets/ChronoMindWidgetBundle.swift
Normal file
15
ios/ChronoMindWidgets/ChronoMindWidgetBundle.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// ── Widget Bundle ─────────────────────────────────────────────
|
||||||
|
// WidgetKit extension containing all ChronoMind widgets + Live Activity
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ChronoMindWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
NextTimerWidget()
|
||||||
|
TimerListWidget()
|
||||||
|
NextTimerLockScreenWidget()
|
||||||
|
TimerLiveActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/ChronoMindWidgets/ChronoMindWidgets.entitlements
Normal file
10
ios/ChronoMindWidgets/ChronoMindWidgets.entitlements
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.chronomind.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
215
ios/ChronoMindWidgets/NextTimerLockScreenWidget.swift
Normal file
215
ios/ChronoMindWidgets/NextTimerLockScreenWidget.swift
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
// ── Lock Screen Widget ────────────────────────────────────────
|
||||||
|
// Lock screen inline/circular/rectangular widgets for next timer
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
// MARK: - Timeline Provider
|
||||||
|
|
||||||
|
struct LockScreenTimerProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = LockScreenTimerEntry
|
||||||
|
typealias Intent = LockScreenTimerIntent
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> LockScreenTimerEntry {
|
||||||
|
LockScreenTimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
label: "Standup",
|
||||||
|
targetTime: Date().addingTimeInterval(3600),
|
||||||
|
urgency: .important,
|
||||||
|
hasTimer: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: LockScreenTimerIntent, in context: Context) async -> LockScreenTimerEntry {
|
||||||
|
entryFromSharedData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: LockScreenTimerIntent, in context: Context) async -> Timeline<LockScreenTimerEntry> {
|
||||||
|
let entry = entryFromSharedData()
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
var entries: [LockScreenTimerEntry] = []
|
||||||
|
for minuteOffset in stride(from: 0, through: 30, by: 5) {
|
||||||
|
let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: now)!
|
||||||
|
entries.append(LockScreenTimerEntry(
|
||||||
|
date: entryDate,
|
||||||
|
label: entry.label,
|
||||||
|
targetTime: entry.targetTime,
|
||||||
|
urgency: entry.urgency,
|
||||||
|
hasTimer: entry.hasTimer
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
let reloadDate: Date
|
||||||
|
if entry.hasTimer {
|
||||||
|
reloadDate = min(entry.targetTime, now.addingTimeInterval(30 * 60))
|
||||||
|
} else {
|
||||||
|
reloadDate = now.addingTimeInterval(30 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: entries, policy: .after(reloadDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entryFromSharedData() -> LockScreenTimerEntry {
|
||||||
|
if let timer = SharedTimerDataManager.shared.readNextFiringTimer() {
|
||||||
|
return LockScreenTimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
label: timer.label,
|
||||||
|
targetTime: timer.targetTime,
|
||||||
|
urgency: timer.urgency,
|
||||||
|
hasTimer: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return LockScreenTimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
label: "",
|
||||||
|
targetTime: Date(),
|
||||||
|
urgency: .standard,
|
||||||
|
hasTimer: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Intent
|
||||||
|
|
||||||
|
struct LockScreenTimerIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = "Lock Screen Timer"
|
||||||
|
static var description: IntentDescription = "Shows next timer countdown on lock screen"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry
|
||||||
|
|
||||||
|
struct LockScreenTimerEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let label: String
|
||||||
|
let targetTime: Date
|
||||||
|
let urgency: UrgencyLevel
|
||||||
|
let hasTimer: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Views
|
||||||
|
|
||||||
|
struct LockScreenInlineView: View {
|
||||||
|
let entry: LockScreenTimerEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
Text(entry.label)
|
||||||
|
Text(entry.targetTime, style: .timer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
Text("No timers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LockScreenCircularView: View {
|
||||||
|
let entry: LockScreenTimerEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
ZStack {
|
||||||
|
// Progress ring based on remaining time
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text(entry.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.widgetAccentable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LockScreenRectangularView: View {
|
||||||
|
let entry: LockScreenTimerEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.label)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(entry.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||||
|
Text(formatTime(entry.targetTime))
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("ChronoMind")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
Text("No active timers")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Definition
|
||||||
|
|
||||||
|
// MARK: - Family Dispatcher
|
||||||
|
|
||||||
|
struct LockScreenWidgetEntryView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: LockScreenTimerEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryInline:
|
||||||
|
LockScreenInlineView(entry: entry)
|
||||||
|
case .accessoryCircular:
|
||||||
|
LockScreenCircularView(entry: entry)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
LockScreenRectangularView(entry: entry)
|
||||||
|
default:
|
||||||
|
LockScreenRectangularView(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Definition
|
||||||
|
|
||||||
|
struct NextTimerLockScreenWidget: Widget {
|
||||||
|
let kind: String = "NextTimerLockScreenWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: LockScreenTimerIntent.self,
|
||||||
|
provider: LockScreenTimerProvider()
|
||||||
|
) { entry in
|
||||||
|
LockScreenWidgetEntryView(entry: entry)
|
||||||
|
.containerBackground(for: .widget) { }
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Timer Countdown")
|
||||||
|
.description("Shows next timer countdown on your lock screen")
|
||||||
|
.supportedFamilies([
|
||||||
|
.accessoryInline,
|
||||||
|
.accessoryCircular,
|
||||||
|
.accessoryRectangular,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
183
ios/ChronoMindWidgets/NextTimerWidget.swift
Normal file
183
ios/ChronoMindWidgets/NextTimerWidget.swift
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// ── Next Timer Widget (Small) ─────────────────────────────────
|
||||||
|
// Home screen small widget showing next timer + countdown
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: - Timeline Provider
|
||||||
|
|
||||||
|
struct NextTimerProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = NextTimerEntry
|
||||||
|
typealias Intent = NextTimerIntent
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> NextTimerEntry {
|
||||||
|
NextTimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
timer: TimerSnapshot(
|
||||||
|
id: "placeholder",
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: NextTimerIntent, in context: Context) async -> NextTimerEntry {
|
||||||
|
let timer = SharedTimerDataManager.shared.readNextFiringTimer()
|
||||||
|
return NextTimerEntry(date: Date(), timer: timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: NextTimerIntent, in context: Context) async -> Timeline<NextTimerEntry> {
|
||||||
|
let timer = SharedTimerDataManager.shared.readNextFiringTimer()
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
// Create entries every 5 minutes for the next 30 minutes
|
||||||
|
var entries: [NextTimerEntry] = []
|
||||||
|
for minuteOffset in stride(from: 0, through: 30, by: 5) {
|
||||||
|
let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: now)!
|
||||||
|
entries.append(NextTimerEntry(date: entryDate, timer: timer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload after 30 minutes or when timer fires
|
||||||
|
let reloadDate: Date
|
||||||
|
if let timer = timer {
|
||||||
|
reloadDate = min(timer.targetTime, now.addingTimeInterval(30 * 60))
|
||||||
|
} else {
|
||||||
|
reloadDate = now.addingTimeInterval(30 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: entries, policy: .after(reloadDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Intent (no config needed, just a placeholder for AppIntentTimelineProvider)
|
||||||
|
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
struct NextTimerIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = "Next Timer"
|
||||||
|
static var description: IntentDescription = "Shows your next upcoming timer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry
|
||||||
|
|
||||||
|
struct NextTimerEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let timer: TimerSnapshot?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget View
|
||||||
|
|
||||||
|
struct NextTimerWidgetView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: NextTimerEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let timer = entry.timer {
|
||||||
|
timerContent(timer)
|
||||||
|
} else {
|
||||||
|
emptyContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func timerContent(_ timer: TimerSnapshot) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Urgency indicator + label
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(urgencyColor(timer.urgency))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(timer.label)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Countdown using SwiftUI Date-relative text
|
||||||
|
if timer.state == .paused {
|
||||||
|
Text("Paused")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(timer.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target time
|
||||||
|
Text(formatTime(timer.targetTime))
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.containerBackground(for: .widget) {
|
||||||
|
urgencyColor(timer.urgency).opacity(0.1)
|
||||||
|
}
|
||||||
|
.widgetURL(URL(string: "chronomind://timer/\(timer.id)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyContent: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No Timers")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Tap to create one")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.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 NextTimerWidget: Widget {
|
||||||
|
let kind: String = "NextTimerWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: NextTimerIntent.self,
|
||||||
|
provider: NextTimerProvider()
|
||||||
|
) { entry in
|
||||||
|
NextTimerWidgetView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Next Timer")
|
||||||
|
.description("Shows your next upcoming timer with countdown")
|
||||||
|
.supportedFamilies([.systemSmall])
|
||||||
|
}
|
||||||
|
}
|
||||||
214
ios/ChronoMindWidgets/TimerListWidget.swift
Normal file
214
ios/ChronoMindWidgets/TimerListWidget.swift
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
// ── 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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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])
|
||||||
|
}
|
||||||
|
}
|
||||||
215
ios/ChronoMindWidgets/TimerLiveActivity.swift
Normal file
215
ios/ChronoMindWidgets/TimerLiveActivity.swift
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
// ── Timer Live Activity ───────────────────────────────────────
|
||||||
|
// Dynamic Island + Lock Screen Live Activity for active timer countdown
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
// MARK: - Live Activity Widget
|
||||||
|
|
||||||
|
struct TimerLiveActivity: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: TimerActivityAttributes.self) { context in
|
||||||
|
// Lock Screen Live Activity
|
||||||
|
lockScreenView(context: context)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
// Expanded regions
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
expandedLeading(context: context)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
expandedTrailing(context: context)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.center) {
|
||||||
|
expandedCenter(context: context)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
expandedBottom(context: context)
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
// Compact leading — urgency dot + label
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(colorFromHex(context.state.urgencyHex))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(context.attributes.timerLabel)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: 60)
|
||||||
|
}
|
||||||
|
} compactTrailing: {
|
||||||
|
// Compact trailing — countdown
|
||||||
|
Text(context.state.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(colorFromHex(context.state.urgencyHex))
|
||||||
|
.frame(minWidth: 40)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
} minimal: {
|
||||||
|
// Minimal — just countdown
|
||||||
|
Text(context.state.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock Screen View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lockScreenView(context: ActivityViewContext<TimerActivityAttributes>) -> some View {
|
||||||
|
let urgencyColor = colorFromHex(context.state.urgencyHex)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
// Urgency indicator + label
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(urgencyColor)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(context.attributes.timerLabel)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Pomodoro round indicator
|
||||||
|
if let round = context.state.pomodoroRound,
|
||||||
|
let total = context.state.pomodoroTotal {
|
||||||
|
Text(context.state.isBreak ? "Break" : "Round \(round)/\(total)")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(urgencyColor.opacity(0.3))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .bottom) {
|
||||||
|
// Large countdown
|
||||||
|
Text(context.state.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 36, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Target time
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("fires at")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
Text(context.state.targetTime, style: .time)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urgency progress bar
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(.white.opacity(0.15))
|
||||||
|
.frame(height: 3)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(urgencyColor)
|
||||||
|
.frame(width: max(0, geo.size.width * progressFraction(context.state)), height: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.activityBackgroundTint(Color.black.opacity(0.85))
|
||||||
|
.activitySystemActionForegroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dynamic Island Expanded Views
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func expandedLeading(context: ActivityViewContext<TimerActivityAttributes>) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(context.attributes.timerLabel)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let round = context.state.pomodoroRound,
|
||||||
|
let total = context.state.pomodoroTotal {
|
||||||
|
Text(context.state.isBreak ? "Break" : "Round \(round)/\(total)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func expandedTrailing(context: ActivityViewContext<TimerActivityAttributes>) -> some View {
|
||||||
|
Text(context.state.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 20, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(colorFromHex(context.state.urgencyHex))
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func expandedCenter(context: ActivityViewContext<TimerActivityAttributes>) -> some View {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func expandedBottom(context: ActivityViewContext<TimerActivityAttributes>) -> some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
// Fires at
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(context.state.targetTime, style: .time)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Type indicator
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: typeIcon(context.attributes.timerType))
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(context.attributes.timerType.capitalized)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func progressFraction(_ state: TimerActivityAttributes.ContentState) -> CGFloat {
|
||||||
|
let remaining = state.targetTime.timeIntervalSinceNow
|
||||||
|
let total = max(1, TimeInterval(state.remainingSeconds))
|
||||||
|
let fraction = 1.0 - (remaining / total)
|
||||||
|
return CGFloat(min(1, max(0, fraction)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func colorFromHex(_ hex: String) -> Color {
|
||||||
|
switch hex {
|
||||||
|
case "#FF4757": return Color(red: 1.0, green: 0.28, blue: 0.34)
|
||||||
|
case "#FF9F43": return Color(red: 1.0, green: 0.62, blue: 0.26)
|
||||||
|
case "#FECA57": return Color(red: 0.99, green: 0.79, blue: 0.34)
|
||||||
|
case "#2ED573": return Color(red: 0.18, green: 0.84, blue: 0.45)
|
||||||
|
case "#A5B1C7": return Color(red: 0.65, green: 0.69, blue: 0.78)
|
||||||
|
default: return Color(red: 0.36, green: 0.55, blue: 0.93) // accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func typeIcon(_ type: String) -> String {
|
||||||
|
switch type {
|
||||||
|
case "alarm": return "alarm.fill"
|
||||||
|
case "countdown": return "timer"
|
||||||
|
case "pomodoro": return "target"
|
||||||
|
case "event": return "calendar"
|
||||||
|
default: return "clock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user