feat(watch,mac): complete watchOS + macOS companion targets
- watchOS: add WatchSessionManager (WCSession bridge), WatchNotificationHandler (snooze/dismiss actions), recommendations() for AppIntentTimelineProvider - watchOS: WatchTimerStore tick loop with cascade haptics, App Group sync - macOS: CreateTimerSheet (countdown/alarm/pomodoro), launch-at-login toggle - macOS: MenuBarState showCreateSheet, MacSettingsView data tab - Xcode project updated with new file references
This commit is contained in:
parent
a865b9d655
commit
d179c4c624
@ -37,6 +37,7 @@
|
||||
32665033E2BC63790F7AC21A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A574A113648729BDB180DC6 /* Config.swift */; };
|
||||
327CE7DB4C7C15AF8DA2B62A /* FeatureFlagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB8E3C2B7FD90E1FA218B01 /* FeatureFlagService.swift */; };
|
||||
33FE05A7CC65C5A251D5C686 /* CascadeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC3C28A0A106D3A02642F48 /* CascadeTests.swift */; };
|
||||
34490943BF5BF5F1AC51CBB4 /* CreateTimerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */; };
|
||||
383A8D81DE756B7B927380C7 /* Urgency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8775EEA5055E7416149B8384 /* Urgency.swift */; };
|
||||
3A536FC42B293811D9C2FFBE /* ByteLystPlatformSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 421BDB267D3349C06FADAA28 /* ByteLystPlatformSDK */; };
|
||||
3BC2A4D25FCF4BDA4E815A5D /* MacSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A54834F3BE0671164B5552F /* MacSettingsView.swift */; };
|
||||
@ -77,6 +78,7 @@
|
||||
880CCDF13CFBAC1A05A9900D /* TelemetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 355EAC56ED412303126019DA /* TelemetryService.swift */; };
|
||||
8A0C48D4161E6AAAA2A07E1A /* ChronoMindMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C51DECA2DE40C002338930E /* ChronoMindMacApp.swift */; };
|
||||
8AC85822AA4E433E857DA4A5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC158E525F3F703034E5542 /* Format.swift */; };
|
||||
8EA608B60672AC6A8056AA71 /* WatchNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */; };
|
||||
8EF5C79BDF55D1F04851C3C0 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2F36FE2E4FEAD385B6860 /* TimelineView.swift */; };
|
||||
9018CB5B25A5B9609510CFF8 /* SharedTimerData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C298B338633A7D75102171 /* SharedTimerData.swift */; };
|
||||
95B261AD4DFE1681A86BAD90 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A574A113648729BDB180DC6 /* Config.swift */; };
|
||||
@ -100,6 +102,7 @@
|
||||
AC65C4971CDEF04A0BBCBCA6 /* TimerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93579D521BE5D6682B129A5C /* TimerEngine.swift */; };
|
||||
AE00522D3CE642B0C608E8A2 /* CountdownRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE814566D06D5ED5DE214765 /* CountdownRing.swift */; };
|
||||
B586A949950B02D3499C828E /* TimeBlindness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D899FC687E51E9606B813E4F /* TimeBlindness.swift */; };
|
||||
B73F1243A121C80414DE0AF4 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */; };
|
||||
B7924EB8DA38D8A3CD30DE8F /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29B4D4514DB0134359A13E /* KeychainHelper.swift */; };
|
||||
B79A0BFA53743E32ADC391A2 /* TimeBlindness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D899FC687E51E9606B813E4F /* TimeBlindness.swift */; };
|
||||
B8AB20C0CB248C63CB3492A5 /* Routines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880334677DB319D5189434D0 /* Routines.swift */; };
|
||||
@ -229,8 +232,10 @@
|
||||
38EB42DB96BAF7C05809A7D9 /* TimerAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerAppIntents.swift; sourceTree = "<group>"; };
|
||||
3979E2F91CF5CD721AB63632 /* ChronoMindMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChronoMindMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3A54834F3BE0671164B5552F /* MacSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacSettingsView.swift; sourceTree = "<group>"; };
|
||||
3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
|
||||
439857A424E7419653DBC47B /* WatchContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContentView.swift; sourceTree = "<group>"; };
|
||||
4438C3D29B6F307E453E9EC1 /* SleepManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepManager.swift; sourceTree = "<group>"; };
|
||||
450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTimerSheet.swift; sourceTree = "<group>"; };
|
||||
453FAEB975BAA8307C8AED26 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
479A9078C8BF70F342BE7A1C /* BadgeGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeGridView.swift; sourceTree = "<group>"; };
|
||||
492793555CC220F85136F2B4 /* TimerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerCard.swift; sourceTree = "<group>"; };
|
||||
@ -272,6 +277,7 @@
|
||||
B3A5D547D31EEFB16F3491EA /* ContextMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMessages.swift; sourceTree = "<group>"; };
|
||||
B769E9E88032FB471820D110 /* RoutineRunnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRunnerView.swift; sourceTree = "<group>"; };
|
||||
B7AC8BE54BDA744FCCA8C5A1 /* LocationTriggerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTriggerManager.swift; sourceTree = "<group>"; };
|
||||
BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationHandler.swift; sourceTree = "<group>"; };
|
||||
BD88A07063BD00F2DC6BB753 /* ChronoMindApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChronoMindApp.swift; sourceTree = "<group>"; };
|
||||
BE3B45641C99D54A016AD38E /* NLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLParser.swift; sourceTree = "<group>"; };
|
||||
BEE32B08F25A4438A6B69FB6 /* ChronoMindTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = ChronoMindTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@ -504,6 +510,7 @@
|
||||
6A8791D6748EAE1B7BA105B5 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */,
|
||||
3A54834F3BE0671164B5552F /* MacSettingsView.swift */,
|
||||
CD4D2E4380D018CAF3C8B82F /* MenuBarPopover.swift */,
|
||||
);
|
||||
@ -679,6 +686,8 @@
|
||||
FBC821ED7751399B034F12DF /* Views */,
|
||||
1E2C2086B4B2E599F4EE7D9D /* ChronoMindWatch.entitlements */,
|
||||
162EA6A89620EC9ED71707E1 /* ChronoMindWatchApp.swift */,
|
||||
BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */,
|
||||
3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */,
|
||||
0625840216F70EFAB4A21985 /* WatchTimerStore.swift */,
|
||||
);
|
||||
path = ChronoMindWatch;
|
||||
@ -1032,6 +1041,7 @@
|
||||
3E6BD90AD48861E7FB3293D9 /* CloudKitSyncManager.swift in Sources */,
|
||||
32665033E2BC63790F7AC21A /* Config.swift in Sources */,
|
||||
E0A6D4C09DE2F6269FFCC4CB /* ContextMessages.swift in Sources */,
|
||||
34490943BF5BF5F1AC51CBB4 /* CreateTimerSheet.swift in Sources */,
|
||||
59AF6F8E533757C8ED232857 /* FeatureFlagService.swift in Sources */,
|
||||
0BD15496ADEDDF47400ACFAE /* Format.swift in Sources */,
|
||||
4E897C117548C3E0895782F3 /* GamificationEngine.swift in Sources */,
|
||||
@ -1108,7 +1118,9 @@
|
||||
AC65C4971CDEF04A0BBCBCA6 /* TimerEngine.swift in Sources */,
|
||||
C7B2C7C46C8929AC4C8F09D4 /* Urgency.swift in Sources */,
|
||||
F68B39CF4045DA16BD3FF38A /* WatchContentView.swift in Sources */,
|
||||
8EA608B60672AC6A8056AA71 /* WatchNotificationHandler.swift in Sources */,
|
||||
7574D49CE352EFD54B38ED54 /* WatchQuickTimerView.swift in Sources */,
|
||||
B73F1243A121C80414DE0AF4 /* WatchSessionManager.swift in Sources */,
|
||||
96B75E94FDFB11235CD67F49 /* WatchTimerDetailView.swift in Sources */,
|
||||
2006E7190D828C4F92FF0A5E /* WatchTimerStore.swift in Sources */,
|
||||
);
|
||||
|
||||
@ -14,6 +14,10 @@ struct ChronoMindMacApp: App {
|
||||
MenuBarPopover()
|
||||
.environmentObject(timerStore)
|
||||
.environmentObject(menuBarState)
|
||||
.sheet(isPresented: $menuBarState.showCreateSheet) {
|
||||
CreateTimerSheet()
|
||||
.environmentObject(timerStore)
|
||||
}
|
||||
} label: {
|
||||
menuBarLabel
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ final class MenuBarState: ObservableObject {
|
||||
|
||||
@Published var isExpanded = false
|
||||
@Published var showQuickTimer = false
|
||||
@Published var showCreateSheet = false
|
||||
@Published var quickTimerLabel = ""
|
||||
@Published var quickTimerMinutes: Double = 25
|
||||
|
||||
|
||||
166
ios/ChronoMindMac/Views/CreateTimerSheet.swift
Normal file
166
ios/ChronoMindMac/Views/CreateTimerSheet.swift
Normal file
@ -0,0 +1,166 @@
|
||||
// Sheet for creating a new timer from the macOS menu bar popover
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CreateTimerSheet: View {
|
||||
@EnvironmentObject var store: MacTimerStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var label = ""
|
||||
@State private var timerType: CMTimerType = .countdown
|
||||
@State private var hours: Int = 0
|
||||
@State private var minutes: Int = 25
|
||||
@State private var alarmTime = Date().addingTimeInterval(3600)
|
||||
@State private var pomodoroRounds: Int = 4
|
||||
@State private var workMinutes: Int = 25
|
||||
@State private var breakMinutes: Int = 5
|
||||
@State private var urgency: UrgencyLevel = .standard
|
||||
@State private var cascadePreset: CascadePreset = .standard
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("New Timer")
|
||||
.font(CMFonts.display(size: 16))
|
||||
.foregroundStyle(CMColors.text)
|
||||
Spacer()
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().background(CMColors.border)
|
||||
|
||||
// Form
|
||||
Form {
|
||||
TextField("Timer Label", text: $label)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Picker("Type", selection: $timerType) {
|
||||
Text("Countdown").tag(CMTimerType.countdown)
|
||||
Text("Alarm").tag(CMTimerType.alarm)
|
||||
Text("Pomodoro").tag(CMTimerType.pomodoro)
|
||||
}
|
||||
|
||||
switch timerType {
|
||||
case .countdown:
|
||||
countdownFields
|
||||
case .alarm:
|
||||
alarmFields
|
||||
case .pomodoro:
|
||||
pomodoroFields
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
Picker("Urgency", selection: $urgency) {
|
||||
ForEach(UrgencyLevel.allCases) { level in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(CMColors.urgencyColor(level))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(getUrgencyConfig(level).label)
|
||||
}
|
||||
.tag(level)
|
||||
}
|
||||
}
|
||||
|
||||
if timerType != .pomodoro {
|
||||
Picker("Cascade", selection: $cascadePreset) {
|
||||
ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in
|
||||
Text(preset.label).tag(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(CMColors.bg)
|
||||
|
||||
Divider().background(CMColors.border)
|
||||
|
||||
// Actions
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Create") {
|
||||
createTimer()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(label.isEmpty && timerType != .pomodoro)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(width: 360, height: 420)
|
||||
.background(CMColors.bg)
|
||||
}
|
||||
|
||||
// MARK: - Type-Specific Fields
|
||||
|
||||
private var countdownFields: some View {
|
||||
HStack {
|
||||
Stepper("Hours: \(hours)", value: $hours, in: 0...23)
|
||||
Stepper("Minutes: \(minutes)", value: $minutes, in: 0...59)
|
||||
}
|
||||
}
|
||||
|
||||
private var alarmFields: some View {
|
||||
DatePicker("Target Time", selection: $alarmTime, displayedComponents: [.date, .hourAndMinute])
|
||||
}
|
||||
|
||||
private var pomodoroFields: some View {
|
||||
Group {
|
||||
Stepper("Rounds: \(pomodoroRounds)", value: $pomodoroRounds, in: 1...12)
|
||||
Stepper("Work: \(workMinutes)m", value: $workMinutes, in: 5...60, step: 5)
|
||||
Stepper("Break: \(breakMinutes)m", value: $breakMinutes, in: 1...30)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
private func createTimer() {
|
||||
let timerLabel = label.isEmpty ? "Focus Session" : label
|
||||
|
||||
switch timerType {
|
||||
case .countdown:
|
||||
let totalSeconds = TimeInterval((hours * 3600) + (minutes * 60))
|
||||
guard totalSeconds > 0 else { return }
|
||||
store.addCountdown(label: timerLabel, durationSeconds: totalSeconds)
|
||||
|
||||
case .alarm:
|
||||
store.addAlarm(label: timerLabel, targetTime: alarmTime, urgency: urgency)
|
||||
|
||||
case .pomodoro:
|
||||
let config = PomodoroConfig(
|
||||
workMinutes: workMinutes,
|
||||
breakMinutes: breakMinutes,
|
||||
longBreakMinutes: breakMinutes * 3,
|
||||
rounds: pomodoroRounds
|
||||
)
|
||||
let timer = createPomodoro(CreatePomodoroParams(
|
||||
label: timerLabel,
|
||||
config: config,
|
||||
urgency: urgency
|
||||
))
|
||||
store.timers.append(timer)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
// ── macOS Settings View ───────────────────────────────────────
|
||||
|
||||
import SwiftUI
|
||||
import ServiceManagement
|
||||
import os
|
||||
|
||||
struct MacSettingsView: View {
|
||||
@EnvironmentObject var store: MacTimerStore
|
||||
@ -8,6 +10,8 @@ struct MacSettingsView: View {
|
||||
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
||||
@AppStorage("cm_launchAtLogin") private var launchAtLogin = false
|
||||
|
||||
private let logger = Logger(subsystem: "com.chronomind.mac", category: "Settings")
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
generalTab
|
||||
@ -26,7 +30,24 @@ struct MacSettingsView: View {
|
||||
|
||||
private var generalTab: some View {
|
||||
Form {
|
||||
Toggle("Launch at Login", isOn: $launchAtLogin)
|
||||
Toggle("Launch at Login", isOn: Binding(
|
||||
get: { launchAtLogin },
|
||||
set: { newValue in
|
||||
launchAtLogin = newValue
|
||||
do {
|
||||
if newValue {
|
||||
try SMAppService.mainApp.register()
|
||||
logger.info("Registered for launch at login")
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
logger.info("Unregistered from launch at login")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Launch at login toggle failed: \(error.localizedDescription)")
|
||||
launchAtLogin = !newValue
|
||||
}
|
||||
}
|
||||
))
|
||||
|
||||
Picker("Default Urgency", selection: $defaultUrgency) {
|
||||
ForEach(UrgencyLevel.allCases) { level in
|
||||
|
||||
@ -50,6 +50,10 @@ struct WatchComplicationProvider: AppIntentTimelineProvider {
|
||||
return Timeline(entries: entries, policy: .after(reloadDate))
|
||||
}
|
||||
|
||||
func recommendations() -> [AppIntentRecommendation<WatchComplicationIntent>] {
|
||||
[AppIntentRecommendation(intent: WatchComplicationIntent(), description: "Next Timer")]
|
||||
}
|
||||
|
||||
private func entryFromSharedData() -> WatchComplicationEntry {
|
||||
if let timer = SharedTimerDataManager.shared.readNextFiringTimer() {
|
||||
return WatchComplicationEntry(
|
||||
|
||||
@ -6,6 +6,7 @@ import WatchKit
|
||||
|
||||
struct WatchTimerDetailView: View {
|
||||
let timer: TimerSnapshot
|
||||
@EnvironmentObject var store: WatchTimerStore
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -79,6 +80,11 @@ struct WatchTimerDetailView: View {
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
actionButtons
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@ -86,6 +92,86 @@ struct WatchTimerDetailView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Action Buttons
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButtons: some View {
|
||||
switch timer.state {
|
||||
case .firing:
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
store.sendCommand(.snooze(id: timer.id, minutes: 5))
|
||||
} label: {
|
||||
Label("Snooze 5m", systemImage: "moon.zzz")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
|
||||
Button {
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
store.sendCommand(.dismiss(id: timer.id))
|
||||
} label: {
|
||||
Label("Dismiss", systemImage: "xmark.circle")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
case .active, .warning:
|
||||
Button {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
store.sendCommand(.pause(id: timer.id))
|
||||
} label: {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
|
||||
case .paused:
|
||||
Button {
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
store.sendCommand(.resume(id: timer.id))
|
||||
} label: {
|
||||
Label("Resume", systemImage: "play.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
|
||||
case .snoozed:
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
store.sendCommand(.snooze(id: timer.id, minutes: 5))
|
||||
} label: {
|
||||
Label("Snooze", systemImage: "moon.zzz")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.orange)
|
||||
|
||||
Button {
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
store.sendCommand(.dismiss(id: timer.id))
|
||||
} label: {
|
||||
Label("Dismiss", systemImage: "xmark.circle")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func urgencyColor(_ urgency: UrgencyLevel) -> Color {
|
||||
switch urgency {
|
||||
case .critical: return .red
|
||||
|
||||
131
ios/ChronoMindWatch/WatchNotificationHandler.swift
Normal file
131
ios/ChronoMindWatch/WatchNotificationHandler.swift
Normal file
@ -0,0 +1,131 @@
|
||||
// Local notification handler for watchOS timer alerts
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import os
|
||||
|
||||
@MainActor
|
||||
final class WatchNotificationHandler: NSObject, ObservableObject {
|
||||
static let shared = WatchNotificationHandler()
|
||||
|
||||
private let logger = Logger(subsystem: "com.chronomind.watch", category: "Notifications")
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
static let timerFiredCategory = "CHRONOMIND_TIMER_FIRED"
|
||||
static let snoozeAction = "SNOOZE_ACTION"
|
||||
static let dismissAction = "DISMISS_ACTION"
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
func requestAuthorization() {
|
||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
Task { @MainActor in
|
||||
if let error = error {
|
||||
self.logger.error("Notification auth error: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
self.logger.info("Notification auth granted: \(granted)")
|
||||
self.registerCategories()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registerCategories() {
|
||||
let snooze = UNNotificationAction(
|
||||
identifier: Self.snoozeAction,
|
||||
title: "Snooze 5m",
|
||||
options: []
|
||||
)
|
||||
let dismiss = UNNotificationAction(
|
||||
identifier: Self.dismissAction,
|
||||
title: "Dismiss",
|
||||
options: [.destructive]
|
||||
)
|
||||
|
||||
let category = UNNotificationCategory(
|
||||
identifier: Self.timerFiredCategory,
|
||||
actions: [snooze, dismiss],
|
||||
intentIdentifiers: [],
|
||||
options: []
|
||||
)
|
||||
|
||||
center.setNotificationCategories([category])
|
||||
center.delegate = self
|
||||
logger.info("Notification categories registered")
|
||||
}
|
||||
|
||||
// MARK: - Schedule
|
||||
|
||||
func scheduleTimerNotification(id: String, label: String, targetTime: Date, urgency: UrgencyLevel) {
|
||||
let interval = targetTime.timeIntervalSinceNow
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Timer Fired"
|
||||
content.body = label
|
||||
content.sound = urgency == .critical ? .defaultCritical : .default
|
||||
content.categoryIdentifier = Self.timerFiredCategory
|
||||
content.userInfo = ["timerId": id, "urgency": urgency.rawValue]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "timer-\(id)", content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
Task { @MainActor in
|
||||
if let error = error {
|
||||
self.logger.error("Failed to schedule notification: \(error.localizedDescription)")
|
||||
} else {
|
||||
self.logger.info("Scheduled notification for timer \(id) in \(Int(interval))s")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelNotification(for timerId: String) {
|
||||
center.removePendingNotificationRequests(withIdentifiers: ["timer-\(timerId)"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
extension WatchNotificationHandler: UNUserNotificationCenterDelegate {
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
guard let timerId = userInfo["timerId"] as? String else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let sessionManager = WatchSessionManager.shared
|
||||
switch response.actionIdentifier {
|
||||
case Self.snoozeAction:
|
||||
sessionManager.sendCommand(.snooze(id: timerId, minutes: 5))
|
||||
logger.info("Snooze action for timer \(timerId)")
|
||||
case Self.dismissAction:
|
||||
sessionManager.sendCommand(.dismiss(id: timerId))
|
||||
logger.info("Dismiss action for timer \(timerId)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
166
ios/ChronoMindWatch/WatchSessionManager.swift
Normal file
166
ios/ChronoMindWatch/WatchSessionManager.swift
Normal file
@ -0,0 +1,166 @@
|
||||
// WCSession manager for bidirectional iPhone ↔ Watch sync
|
||||
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import os
|
||||
|
||||
// MARK: - Watch Timer Command
|
||||
|
||||
enum WatchTimerCommand: Codable {
|
||||
case snooze(id: String, minutes: Int)
|
||||
case dismiss(id: String)
|
||||
case pause(id: String)
|
||||
case resume(id: String)
|
||||
case complete(id: String)
|
||||
|
||||
var messageDict: [String: Any] {
|
||||
switch self {
|
||||
case .snooze(let id, let minutes):
|
||||
return ["command": "snooze", "id": id, "minutes": minutes]
|
||||
case .dismiss(let id):
|
||||
return ["command": "dismiss", "id": id]
|
||||
case .pause(let id):
|
||||
return ["command": "pause", "id": id]
|
||||
case .resume(let id):
|
||||
return ["command": "resume", "id": id]
|
||||
case .complete(let id):
|
||||
return ["command": "complete", "id": id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch Session Manager
|
||||
|
||||
@MainActor
|
||||
final class WatchSessionManager: NSObject, ObservableObject {
|
||||
static let shared = WatchSessionManager()
|
||||
|
||||
@Published var isReachable = false
|
||||
|
||||
private let session: WCSession
|
||||
private let logger = Logger(subsystem: "com.chronomind.watch", category: "WatchSessionManager")
|
||||
private var pendingCommands: [[String: Any]] = []
|
||||
|
||||
private override init() {
|
||||
session = WCSession.default
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Activation
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else {
|
||||
logger.warning("WCSession not supported on this device")
|
||||
return
|
||||
}
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
logger.info("WCSession activation requested")
|
||||
}
|
||||
|
||||
// MARK: - Send Command
|
||||
|
||||
func sendCommand(_ command: WatchTimerCommand) {
|
||||
let message = command.messageDict
|
||||
guard session.activationState == .activated else {
|
||||
logger.warning("Session not activated, queuing command")
|
||||
pendingCommands.append(message)
|
||||
return
|
||||
}
|
||||
|
||||
if session.isReachable {
|
||||
session.sendMessage(message, replyHandler: { reply in
|
||||
Task { @MainActor in
|
||||
self.logger.info("Command acknowledged: \(reply.description)")
|
||||
}
|
||||
}, errorHandler: { error in
|
||||
Task { @MainActor in
|
||||
self.logger.error("Failed to send command: \(error.localizedDescription)")
|
||||
self.pendingCommands.append(message)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Fallback: use application context for non-urgent delivery
|
||||
do {
|
||||
try session.updateApplicationContext(["pendingCommand": message])
|
||||
logger.info("Command sent via application context")
|
||||
} catch {
|
||||
logger.error("Failed to update application context: \(error.localizedDescription)")
|
||||
pendingCommands.append(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flush Pending
|
||||
|
||||
private func flushPendingCommands() {
|
||||
guard session.isReachable, !pendingCommands.isEmpty else { return }
|
||||
let commands = pendingCommands
|
||||
pendingCommands.removeAll()
|
||||
|
||||
for command in commands {
|
||||
session.sendMessage(command, replyHandler: nil) { [weak self] error in
|
||||
Task { @MainActor in
|
||||
self?.logger.error("Failed to flush command: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info("Flushed \(commands.count) pending commands")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension WatchSessionManager: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
Task { @MainActor in
|
||||
if let error = error {
|
||||
logger.error("WCSession activation failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
logger.info("WCSession activated: \(activationState.rawValue)")
|
||||
isReachable = session.isReachable
|
||||
flushPendingCommands()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
Task { @MainActor in
|
||||
isReachable = session.isReachable
|
||||
logger.info("Reachability changed: \(session.isReachable)")
|
||||
if session.isReachable {
|
||||
flushPendingCommands()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
Task { @MainActor in
|
||||
logger.info("Received application context update")
|
||||
// iPhone pushed updated timer data — refresh from App Group
|
||||
NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
Task { @MainActor in
|
||||
logger.info("Received message: \(message.keys.joined(separator: ", "))")
|
||||
// iPhone pushed a real-time update — refresh from App Group
|
||||
NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
Task { @MainActor in
|
||||
logger.info("Received message with reply: \(message.keys.joined(separator: ", "))")
|
||||
NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil)
|
||||
replyHandler(["status": "received"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Name
|
||||
|
||||
extension Notification.Name {
|
||||
static let watchTimerDataUpdated = Notification.Name("watchTimerDataUpdated")
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import WatchKit
|
||||
import os
|
||||
|
||||
@MainActor
|
||||
final class WatchTimerStore: ObservableObject {
|
||||
@ -16,23 +17,59 @@ final class WatchTimerStore: ObservableObject {
|
||||
// MARK: - Private
|
||||
|
||||
private var tickTimer: Timer?
|
||||
private var updateObserver: Any?
|
||||
private let sharedData = SharedTimerDataManager.shared
|
||||
private let notificationHandler = WatchNotificationHandler.shared
|
||||
private let logger = Logger(subsystem: "com.chronomind.watch", category: "WatchTimerStore")
|
||||
|
||||
// Track which warnings we already fired haptics for
|
||||
private var firedWarningKeys: Set<String> = []
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
loadFromSharedData()
|
||||
startTicking()
|
||||
notificationHandler.requestAuthorization()
|
||||
|
||||
// Activate WCSession
|
||||
WatchSessionManager.shared.activate()
|
||||
|
||||
// Listen for real-time updates from iPhone
|
||||
updateObserver = NotificationCenter.default.addObserver(
|
||||
forName: .watchTimerDataUpdated,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.loadFromSharedData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
tickTimer?.invalidate()
|
||||
if let observer = updateObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadFromSharedData() {
|
||||
timers = sharedData.readActiveSnapshots()
|
||||
let newTimers = sharedData.readActiveSnapshots()
|
||||
|
||||
// Schedule notifications for any new timers
|
||||
for timer in newTimers where !timers.contains(where: { $0.id == timer.id }) {
|
||||
notificationHandler.scheduleTimerNotification(
|
||||
id: timer.id,
|
||||
label: timer.label,
|
||||
targetTime: timer.targetTime,
|
||||
urgency: timer.urgency
|
||||
)
|
||||
}
|
||||
|
||||
timers = newTimers
|
||||
}
|
||||
|
||||
// MARK: - Queries
|
||||
@ -48,6 +85,18 @@ final class WatchTimerStore: ObservableObject {
|
||||
timers.filter { [.active, .warning, .snoozed, .firing].contains($0.state) }
|
||||
}
|
||||
|
||||
// MARK: - Commands (via WCSession)
|
||||
|
||||
func sendCommand(_ command: WatchTimerCommand) {
|
||||
WatchSessionManager.shared.sendCommand(command)
|
||||
|
||||
// Refresh after a short delay to pick up state changes
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
loadFromSharedData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick Timer Creation (writes to App Group)
|
||||
|
||||
func createQuickTimer(minutes: Int, label: String) {
|
||||
@ -82,8 +131,17 @@ final class WatchTimerStore: ObservableObject {
|
||||
// Write back to shared data so iOS app picks it up
|
||||
sharedData.writeSnapshots(timers)
|
||||
|
||||
// Schedule notification
|
||||
notificationHandler.scheduleTimerNotification(
|
||||
id: snapshot.id,
|
||||
label: label,
|
||||
targetTime: targetTime,
|
||||
urgency: .standard
|
||||
)
|
||||
|
||||
// Haptic confirmation
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
logger.info("Created quick timer: \(label) for \(minutes)m")
|
||||
}
|
||||
|
||||
// MARK: - Tick
|
||||
@ -105,20 +163,76 @@ final class WatchTimerStore: ObservableObject {
|
||||
loadFromSharedData()
|
||||
}
|
||||
|
||||
// Check for fired timers
|
||||
// Check for warnings and 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)
|
||||
let timer = timers[i]
|
||||
|
||||
// Check cascade warnings
|
||||
if let nextWarning = timer.nextWarningTime,
|
||||
now >= nextWarning,
|
||||
timer.state == .active || timer.state == .warning {
|
||||
let warningKey = "\(timer.id)-\(Int(nextWarning.timeIntervalSince1970))"
|
||||
if !firedWarningKeys.contains(warningKey) {
|
||||
firedWarningKeys.insert(warningKey)
|
||||
playWarningHaptic(urgency: timer.urgency)
|
||||
logger.info("Warning fired for timer \(timer.label)")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for fired timers
|
||||
if timer.state == .active || timer.state == .warning {
|
||||
if now >= timer.targetTime {
|
||||
playFiredHaptic(urgency: timer.urgency)
|
||||
logger.info("Timer fired: \(timer.label)")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
loadFromSharedData() // Refresh to get updated states
|
||||
loadFromSharedData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
private func playWarningHaptic(urgency: UrgencyLevel) {
|
||||
switch urgency {
|
||||
case .critical:
|
||||
// Triple haptic for critical
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
case .important:
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
case .standard, .gentle:
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
case .passive:
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
}
|
||||
}
|
||||
|
||||
private func playFiredHaptic(urgency: UrgencyLevel) {
|
||||
switch urgency {
|
||||
case .critical:
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
default:
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user