From 5936016a36c776ddad9c25ab12abc3203db35abd Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:01:34 -0800 Subject: [PATCH] =?UTF-8?q?feat(widget):=20add=20WidgetKit=20widgets=20?= =?UTF-8?q?=E2=80=94=20small,=20medium,=20lock=20screen=20with=20AppIntent?= =?UTF-8?q?TimelineProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChronoMindWidgetBundle.swift | 15 ++ .../ChronoMindWidgets.entitlements | 10 + .../NextTimerLockScreenWidget.swift | 215 ++++++++++++++++++ ios/ChronoMindWidgets/NextTimerWidget.swift | 183 +++++++++++++++ ios/ChronoMindWidgets/TimerListWidget.swift | 214 +++++++++++++++++ ios/ChronoMindWidgets/TimerLiveActivity.swift | 215 ++++++++++++++++++ 6 files changed, 852 insertions(+) create mode 100644 ios/ChronoMindWidgets/ChronoMindWidgetBundle.swift create mode 100644 ios/ChronoMindWidgets/ChronoMindWidgets.entitlements create mode 100644 ios/ChronoMindWidgets/NextTimerLockScreenWidget.swift create mode 100644 ios/ChronoMindWidgets/NextTimerWidget.swift create mode 100644 ios/ChronoMindWidgets/TimerListWidget.swift create mode 100644 ios/ChronoMindWidgets/TimerLiveActivity.swift diff --git a/ios/ChronoMindWidgets/ChronoMindWidgetBundle.swift b/ios/ChronoMindWidgets/ChronoMindWidgetBundle.swift new file mode 100644 index 0000000..70f83b2 --- /dev/null +++ b/ios/ChronoMindWidgets/ChronoMindWidgetBundle.swift @@ -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() + } +} diff --git a/ios/ChronoMindWidgets/ChronoMindWidgets.entitlements b/ios/ChronoMindWidgets/ChronoMindWidgets.entitlements new file mode 100644 index 0000000..cc45b00 --- /dev/null +++ b/ios/ChronoMindWidgets/ChronoMindWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.chronomind.shared + + + diff --git a/ios/ChronoMindWidgets/NextTimerLockScreenWidget.swift b/ios/ChronoMindWidgets/NextTimerLockScreenWidget.swift new file mode 100644 index 0000000..eb99979 --- /dev/null +++ b/ios/ChronoMindWidgets/NextTimerLockScreenWidget.swift @@ -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 { + 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, + ]) + } +} diff --git a/ios/ChronoMindWidgets/NextTimerWidget.swift b/ios/ChronoMindWidgets/NextTimerWidget.swift new file mode 100644 index 0000000..2731dc9 --- /dev/null +++ b/ios/ChronoMindWidgets/NextTimerWidget.swift @@ -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 { + 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]) + } +} diff --git a/ios/ChronoMindWidgets/TimerListWidget.swift b/ios/ChronoMindWidgets/TimerListWidget.swift new file mode 100644 index 0000000..2066570 --- /dev/null +++ b/ios/ChronoMindWidgets/TimerListWidget.swift @@ -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 { + 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]) + } +} diff --git a/ios/ChronoMindWidgets/TimerLiveActivity.swift b/ios/ChronoMindWidgets/TimerLiveActivity.swift new file mode 100644 index 0000000..68a9eb2 --- /dev/null +++ b/ios/ChronoMindWidgets/TimerLiveActivity.swift @@ -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) -> 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) -> 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) -> 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) -> some View { + EmptyView() + } + + @ViewBuilder + private func expandedBottom(context: ActivityViewContext) -> 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" + } + } +}