216 lines
8.4 KiB
Swift
216 lines
8.4 KiB
Swift
// ── 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"
|
|
}
|
|
}
|
|
}
|