// ── 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" } } }