feat(watch): add watchOS app with timeline, timer detail, quick presets, haptics, and complications

This commit is contained in:
saravanakumardb1 2026-02-27 22:01:39 -08:00
parent 5936016a36
commit 71bda1dab9
7 changed files with 720 additions and 0 deletions

View 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>

View 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)
}
}
}

View 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,
])
}
}

View 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
}
}
}

View 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()
}
}

View 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
}
}
}

View 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
}
}
}