feat(watch): add watchOS app with timeline, timer detail, quick presets, haptics, and complications
This commit is contained in:
parent
5936016a36
commit
71bda1dab9
10
ios/ChronoMindWatch/ChronoMindWatch.entitlements
Normal file
10
ios/ChronoMindWatch/ChronoMindWatch.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>
|
||||||
15
ios/ChronoMindWatch/ChronoMindWatchApp.swift
Normal file
15
ios/ChronoMindWatch/ChronoMindWatchApp.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// ── ChronoMind Watch App Entry Point ──────────────────────────
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ChronoMindWatchApp: App {
|
||||||
|
@StateObject private var watchStore = WatchTimerStore()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
WatchContentView()
|
||||||
|
.environmentObject(watchStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
ios/ChronoMindWatch/Complications/WatchComplications.swift
Normal file
242
ios/ChronoMindWatch/Complications/WatchComplications.swift
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
// ── Watch Complications ───────────────────────────────────────
|
||||||
|
// WidgetKit complications for Apple Watch faces
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: - Complication Timeline Provider
|
||||||
|
|
||||||
|
struct WatchComplicationProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = WatchComplicationEntry
|
||||||
|
typealias Intent = WatchComplicationIntent
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> WatchComplicationEntry {
|
||||||
|
WatchComplicationEntry(
|
||||||
|
date: Date(),
|
||||||
|
label: "Standup",
|
||||||
|
targetTime: Date().addingTimeInterval(3600),
|
||||||
|
urgency: .important,
|
||||||
|
hasTimer: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: WatchComplicationIntent, in context: Context) async -> WatchComplicationEntry {
|
||||||
|
entryFromSharedData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: WatchComplicationIntent, in context: Context) async -> Timeline<WatchComplicationEntry> {
|
||||||
|
let entry = entryFromSharedData()
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
var entries: [WatchComplicationEntry] = []
|
||||||
|
for minuteOffset in stride(from: 0, through: 30, by: 5) {
|
||||||
|
let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: now)!
|
||||||
|
entries.append(WatchComplicationEntry(
|
||||||
|
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(15 * 60))
|
||||||
|
} else {
|
||||||
|
reloadDate = now.addingTimeInterval(15 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: entries, policy: .after(reloadDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entryFromSharedData() -> WatchComplicationEntry {
|
||||||
|
if let timer = SharedTimerDataManager.shared.readNextFiringTimer() {
|
||||||
|
return WatchComplicationEntry(
|
||||||
|
date: Date(),
|
||||||
|
label: timer.label,
|
||||||
|
targetTime: timer.targetTime,
|
||||||
|
urgency: timer.urgency,
|
||||||
|
hasTimer: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return WatchComplicationEntry(
|
||||||
|
date: Date(),
|
||||||
|
label: "",
|
||||||
|
targetTime: Date(),
|
||||||
|
urgency: .standard,
|
||||||
|
hasTimer: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Intent
|
||||||
|
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
struct WatchComplicationIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = "Timer Complication"
|
||||||
|
static var description: IntentDescription = "Shows next timer on watch face"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry
|
||||||
|
|
||||||
|
struct WatchComplicationEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let label: String
|
||||||
|
let targetTime: Date
|
||||||
|
let urgency: UrgencyLevel
|
||||||
|
let hasTimer: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Circular Complication
|
||||||
|
|
||||||
|
struct CircularComplicationView: View {
|
||||||
|
let entry: WatchComplicationEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
ZStack {
|
||||||
|
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: 18))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rectangular Complication
|
||||||
|
|
||||||
|
struct RectangularComplicationView: View {
|
||||||
|
let entry: WatchComplicationEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.label)
|
||||||
|
.font(.system(size: 13, 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: 13, weight: .semibold))
|
||||||
|
Text("No timers")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inline Complication
|
||||||
|
|
||||||
|
struct InlineComplicationView: View {
|
||||||
|
let entry: WatchComplicationEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
Text("\(entry.label) in")
|
||||||
|
Text(entry.targetTime, style: .timer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
Text("No timers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corner Complication
|
||||||
|
|
||||||
|
struct CornerComplicationView: View {
|
||||||
|
let entry: WatchComplicationEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.hasTimer {
|
||||||
|
Text(entry.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
|
.widgetAccentable()
|
||||||
|
.widgetLabel {
|
||||||
|
Text(entry.label)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.widgetLabel {
|
||||||
|
Text("ChronoMind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Family Dispatcher
|
||||||
|
|
||||||
|
struct WatchComplicationEntryView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: WatchComplicationEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
CircularComplicationView(entry: entry)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
RectangularComplicationView(entry: entry)
|
||||||
|
case .accessoryInline:
|
||||||
|
InlineComplicationView(entry: entry)
|
||||||
|
case .accessoryCorner:
|
||||||
|
CornerComplicationView(entry: entry)
|
||||||
|
default:
|
||||||
|
CircularComplicationView(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Definition
|
||||||
|
|
||||||
|
struct WatchTimerComplication: Widget {
|
||||||
|
let kind: String = "WatchTimerComplication"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: WatchComplicationIntent.self,
|
||||||
|
provider: WatchComplicationProvider()
|
||||||
|
) { entry in
|
||||||
|
WatchComplicationEntryView(entry: entry)
|
||||||
|
.containerBackground(for: .widget) { }
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Timer")
|
||||||
|
.description("Shows next timer countdown on your watch face")
|
||||||
|
.supportedFamilies([
|
||||||
|
.accessoryCircular,
|
||||||
|
.accessoryRectangular,
|
||||||
|
.accessoryInline,
|
||||||
|
.accessoryCorner,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
176
ios/ChronoMindWatch/Views/WatchContentView.swift
Normal file
176
ios/ChronoMindWatch/Views/WatchContentView.swift
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// ── Watch Content View ────────────────────────────────────────
|
||||||
|
// Main navigation for watchOS — timeline + quick timer
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WatchContentView: View {
|
||||||
|
@EnvironmentObject var store: WatchTimerStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if store.timers.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
timerList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
store.loadFromSharedData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer List
|
||||||
|
|
||||||
|
private var timerList: some View {
|
||||||
|
List {
|
||||||
|
// Next up section
|
||||||
|
if let next = store.nextFiringTimer {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
WatchTimerDetailView(timer: next)
|
||||||
|
} label: {
|
||||||
|
WatchNextUpCard(timer: next, now: store.now)
|
||||||
|
}
|
||||||
|
.listRowBackground(urgencyColor(next.urgency).opacity(0.15))
|
||||||
|
} header: {
|
||||||
|
Text("NEXT UP")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick timer
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
WatchQuickTimerView()
|
||||||
|
} label: {
|
||||||
|
Label("Quick Timer", systemImage: "plus.circle.fill")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All active timers
|
||||||
|
if store.activeTimers.count > 1 {
|
||||||
|
Section {
|
||||||
|
ForEach(store.activeTimers.filter { $0.id != store.nextFiringTimer?.id }) { timer in
|
||||||
|
NavigationLink {
|
||||||
|
WatchTimerDetailView(timer: timer)
|
||||||
|
} label: {
|
||||||
|
WatchTimerRow(timer: timer, now: store.now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("UPCOMING")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("ChronoMind")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("No Timers")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
WatchQuickTimerView()
|
||||||
|
} label: {
|
||||||
|
Label("Create Timer", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
.navigationTitle("ChronoMind")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urgencyColor(_ urgency: UrgencyLevel) -> Color {
|
||||||
|
switch urgency {
|
||||||
|
case .critical: return .red
|
||||||
|
case .important: return .orange
|
||||||
|
case .standard: return .yellow
|
||||||
|
case .gentle: return .green
|
||||||
|
case .passive: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Next Up Card
|
||||||
|
|
||||||
|
struct WatchNextUpCard: View {
|
||||||
|
let timer: TimerSnapshot
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(timer.label)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(timer.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(urgencyColor(timer.urgency))
|
||||||
|
|
||||||
|
Text(formatTime(timer.targetTime))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urgencyColor(_ urgency: UrgencyLevel) -> Color {
|
||||||
|
switch urgency {
|
||||||
|
case .critical: return .red
|
||||||
|
case .important: return .orange
|
||||||
|
case .standard: return .yellow
|
||||||
|
case .gentle: return .green
|
||||||
|
case .passive: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer Row
|
||||||
|
|
||||||
|
struct WatchTimerRow: View {
|
||||||
|
let timer: TimerSnapshot
|
||||||
|
let now: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Circle()
|
||||||
|
.fill(urgencyColor(timer.urgency))
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(timer.label)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(formatTime(timer.targetTime))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(timer.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(urgencyColor(timer.urgency))
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urgencyColor(_ urgency: UrgencyLevel) -> Color {
|
||||||
|
switch urgency {
|
||||||
|
case .critical: return .red
|
||||||
|
case .important: return .orange
|
||||||
|
case .standard: return .yellow
|
||||||
|
case .gentle: return .green
|
||||||
|
case .passive: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
ios/ChronoMindWatch/Views/WatchQuickTimerView.swift
Normal file
55
ios/ChronoMindWatch/Views/WatchQuickTimerView.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// ── Watch Quick Timer View ────────────────────────────────────
|
||||||
|
// One-tap preset timers for Apple Watch
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
struct WatchQuickTimerView: View {
|
||||||
|
@EnvironmentObject var store: WatchTimerStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let presets: [(minutes: Int, label: String, icon: String)] = [
|
||||||
|
(5, "5 min", "5.circle.fill"),
|
||||||
|
(10, "10 min", "10.circle.fill"),
|
||||||
|
(15, "15 min", "15.circle.fill"),
|
||||||
|
(25, "Pomodoro", "target"),
|
||||||
|
(30, "30 min", "30.circle.fill"),
|
||||||
|
(45, "45 min", "45.circle.fill"),
|
||||||
|
(60, "1 hour", "clock.fill"),
|
||||||
|
(90, "1.5 hours", "clock.badge.checkmark"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
|
ForEach(presets, id: \.minutes) { preset in
|
||||||
|
Button {
|
||||||
|
createTimer(preset)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: preset.icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(preset.label)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.blue.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.navigationTitle("Quick Timer")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTimer(_ preset: (minutes: Int, label: String, icon: String)) {
|
||||||
|
store.createQuickTimer(minutes: preset.minutes, label: "\(preset.label) Timer")
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
98
ios/ChronoMindWatch/Views/WatchTimerDetailView.swift
Normal file
98
ios/ChronoMindWatch/Views/WatchTimerDetailView.swift
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// ── Watch Timer Detail View ───────────────────────────────────
|
||||||
|
// Full detail view for a timer on Apple Watch
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
struct WatchTimerDetailView: View {
|
||||||
|
let timer: TimerSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// Urgency badge
|
||||||
|
Text(timer.urgency.rawValue.uppercased())
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(urgencyColor(timer.urgency))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(urgencyColor(timer.urgency).opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
// Timer label
|
||||||
|
Text(timer.label)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Large countdown
|
||||||
|
Text(timer.targetTime, style: .timer)
|
||||||
|
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(urgencyColor(timer.urgency))
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
|
||||||
|
// Target time
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text(formatTime(timer.targetTime))
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Pomodoro info
|
||||||
|
if let round = timer.pomodoroCurrentRound,
|
||||||
|
let total = timer.pomodoroTotalRounds {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "target")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text(timer.pomodoroIsBreak == true ? "Break" : "Round \(round)/\(total)")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade progress
|
||||||
|
if timer.totalWarnings > 0 {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("Warnings")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(timer.firedWarnings)/\(timer.totalWarnings)")
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ProgressView(value: Double(timer.firedWarnings), total: Double(timer.totalWarnings))
|
||||||
|
.tint(urgencyColor(timer.urgency))
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze info
|
||||||
|
if timer.snoozeCount > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "zzz")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text("Snoozed \(timer.snoozeCount)x")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(timer.type.rawValue.capitalized)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urgencyColor(_ urgency: UrgencyLevel) -> Color {
|
||||||
|
switch urgency {
|
||||||
|
case .critical: return .red
|
||||||
|
case .important: return .orange
|
||||||
|
case .standard: return .yellow
|
||||||
|
case .gentle: return .green
|
||||||
|
case .passive: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
ios/ChronoMindWatch/WatchTimerStore.swift
Normal file
124
ios/ChronoMindWatch/WatchTimerStore.swift
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// ── Watch Timer Store ─────────────────────────────────────────
|
||||||
|
// Reads timer data from App Group shared by the iOS app
|
||||||
|
// Lightweight store for watchOS — read-only from shared data + local quick timers
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WatchTimerStore: ObservableObject {
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
@Published var timers: [TimerSnapshot] = []
|
||||||
|
@Published var now: Date = Date()
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var tickTimer: Timer?
|
||||||
|
private let sharedData = SharedTimerDataManager.shared
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
loadFromSharedData()
|
||||||
|
startTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
tickTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
func loadFromSharedData() {
|
||||||
|
timers = sharedData.readActiveSnapshots()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queries
|
||||||
|
|
||||||
|
var nextFiringTimer: TimerSnapshot? {
|
||||||
|
timers
|
||||||
|
.filter { [.active, .warning].contains($0.state) }
|
||||||
|
.sorted { $0.targetTime < $1.targetTime }
|
||||||
|
.first
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTimers: [TimerSnapshot] {
|
||||||
|
timers.filter { [.active, .warning, .snoozed, .firing].contains($0.state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Timer Creation (writes to App Group)
|
||||||
|
|
||||||
|
func createQuickTimer(minutes: Int, label: String) {
|
||||||
|
let now = Date()
|
||||||
|
let targetTime = now.addingTimeInterval(TimeInterval(minutes * 60))
|
||||||
|
let intervals = CascadePreset.minimal.defaultIntervals
|
||||||
|
|
||||||
|
let snapshot = TimerSnapshot(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
label: label,
|
||||||
|
type: .countdown,
|
||||||
|
urgency: .standard,
|
||||||
|
state: .active,
|
||||||
|
targetTime: targetTime,
|
||||||
|
duration: TimeInterval(minutes * 60),
|
||||||
|
startedAt: now,
|
||||||
|
elapsedBeforePause: 0,
|
||||||
|
snoozeCount: 0,
|
||||||
|
category: nil,
|
||||||
|
pomodoroCurrentRound: nil,
|
||||||
|
pomodoroTotalRounds: nil,
|
||||||
|
pomodoroIsBreak: nil,
|
||||||
|
nextWarningTime: intervals.first.map { targetTime.addingTimeInterval(-Double($0) * 60) },
|
||||||
|
totalWarnings: intervals.count,
|
||||||
|
firedWarnings: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to local list and persist
|
||||||
|
timers.append(snapshot)
|
||||||
|
timers.sort { $0.targetTime < $1.targetTime }
|
||||||
|
|
||||||
|
// Write back to shared data so iOS app picks it up
|
||||||
|
sharedData.writeSnapshots(timers)
|
||||||
|
|
||||||
|
// Haptic confirmation
|
||||||
|
WKInterfaceDevice.current().play(.success)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tick
|
||||||
|
|
||||||
|
private func startTicking() {
|
||||||
|
tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick() {
|
||||||
|
now = Date()
|
||||||
|
|
||||||
|
// Refresh from shared data every 30 seconds
|
||||||
|
let interval = Int(now.timeIntervalSince1970) % 30
|
||||||
|
if interval == 0 {
|
||||||
|
loadFromSharedData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for fired timers
|
||||||
|
var changed = false
|
||||||
|
for i in timers.indices {
|
||||||
|
if timers[i].state == .active || timers[i].state == .warning {
|
||||||
|
if now >= timers[i].targetTime {
|
||||||
|
// Timer has fired — haptic alert
|
||||||
|
WKInterfaceDevice.current().play(.notification)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
loadFromSharedData() // Refresh to get updated states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user