mycode/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
songyc macbook 3dd53c4fc6 refactor: decouple all UI preferences and layout settings from CloudKit sync to local device storage
- Add Shared/LocalPrefs.swift: device-local UI preferences stored in App Group
  UserDefaults (never synced by iCloud), read identically by app, widgets,
  watch snapshot, and App Intents on the same device
- Move to local storage: main-tab action button order (UUID array), pinned
  goal-progress cards on the main tab, goal quest-list collapse state, and
  per-action memo-prompt popup — reordering or repinning on iPhone no longer
  touches the iPad and vice versa
- Defense logic: actions not in the local order list (e.g. newly received via
  CloudKit from another device) are automatically appended to the end, sorted
  by legacy sortOrder then creation date
- One-time migration adopts existing @Model values (sortOrder, showsOnMain,
  isCollapsed, promptsForNote) into local prefs at launch; model properties
  stay in place so the CloudKit schema is untouched and records keep syncing
- Data remains fully synced: actions, goals, quests, sessions, count entries,
  statistics, and goal/tag/quest list order are untouched by this change
- Verified in simulator: legacy data migrates losslessly, grid renders from
  local order, unknown action lands at the end; iOS+watch+widgets build clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-11 20:58:34 +09:00

231 lines
10 KiB
Swift

//
// WatchSyncManager.swift
// Haru_Danim
//
// Apple Watch (CLAUDE.md §9.1)
// - DB
// - updateApplicationContext
//
import Foundation
import OSLog
import SwiftData
import WatchConnectivity
import WidgetKit
private let watchSyncLog = Logger(subsystem: "com.yechan.HaruDanim", category: "WatchSync")
@MainActor
final class WatchSyncManager: NSObject {
static let shared = WatchSyncManager()
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
#if DEBUG
watchSyncLog.notice("responds userInfo=\(self.responds(to: NSSelectorFromString("session:didReceiveUserInfo:"))) message=\(self.responds(to: NSSelectorFromString("session:didReceiveMessage:replyHandler:")))")
#endif
}
// MARK:
/// DB
func makeSnapshot() -> WatchSnapshot {
let context = DataStore.shared.mainContext
let now = Date.now
let math = DayMath()
let agg = Aggregator(math: math)
var snapshot = WatchSnapshot()
snapshot.isPremium = PremiumGate.isUnlocked(.watchApp)
let tags = (try? context.fetch(FetchDescriptor<Tag>(
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
snapshot.tags = tags.map { WatchTagInfo(id: $0.uuid, name: $0.name, colorHex: $0.colorHex) }
// ( ) ( )
let actions = LocalPrefs.orderedActions(
(try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
)
snapshot.actions = actions.map { action in
let todayValue = agg.todayValue(for: action, now: now)
let isCount = action.trackingType == .count
return WatchActionInfo(
id: action.uuid,
name: action.name,
symbolName: action.symbolName,
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F",
isCount: isCount,
todayValue: todayValue,
isRunning: action.isRunning,
tickingBase: action.isRunning && !isCount ? now.addingTimeInterval(-todayValue) : nil,
tagIDs: action.sortedTags.map(\.uuid)
)
}
let goals = (try? context.fetch(FetchDescriptor<Goal>(
predicate: #Predicate { $0.statusRaw == "inProgress" },
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
snapshot.goals = goals.map { goal in
WatchGoalInfo(
id: goal.uuid,
title: goal.title,
symbolName: goal.symbolName,
colorHex: goal.colorHex,
dayRatio: goal.combinedSpanRatio(.day, math: math, now: now),
weekRatio: goal.combinedSpanRatio(.week, math: math, now: now),
monthRatio: goal.combinedSpanRatio(.month, math: math, now: now),
quests: goal.sortedQuests.map { quest in
let progress = QuestProgress(quest: quest, math: math)
let day = progress.spanProgress(.day, now: now)
let week = progress.spanProgress(.week, now: now)
let month = progress.spanProgress(.month, now: now)
return WatchQuestInfo(
id: quest.uuid,
name: quest.targetName,
symbolName: quest.targetSymbol,
colorHex: quest.targetAction?.sortedTags.first?.colorHex
?? quest.targetTag?.colorHex ?? "#2F6B4F",
dayRatio: day.ratio,
weekRatio: week.ratio,
monthRatio: month.ratio,
isAtMost: quest.direction == .atMost,
dayAchieved: day.isAchieved,
weekAchieved: week.isAchieved,
monthAchieved: month.isAchieved
)
}
)
}
// ( )
let running = (try? context.fetch(FetchDescriptor<TimeSession>(
predicate: #Predicate { $0.endAt == nil },
sortBy: [SortDescriptor(\.startAt, order: .forward)]
))) ?? []
let mode = LiveActivityMode(
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
) ?? .latest
if let representative = mode == .earliest ? running.first : running.last,
let action = representative.action {
snapshot.runningName = action.name
snapshot.runningSymbol = action.symbolName
snapshot.runningStartAt = representative.startAt
snapshot.extraRunningCount = max(0, running.count - 1)
}
return snapshot
}
/// ( )
private var lastComplicationStateKey = ""
private var lastComplicationPushAt = Date.distantPast
/// ( )
func pushSnapshot() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
let snapshot = makeSnapshot()
guard let data = snapshot.encoded() else { return }
// applicationContext:
try? session.updateApplicationContext([WatchSync.snapshotKey: data])
pushForComplicationIfNeeded(session: session, snapshot: snapshot, data: data)
}
/// .
/// 30
private func pushForComplicationIfNeeded(session: WCSession, snapshot: WatchSnapshot, data: Data) {
guard session.isComplicationEnabled else { return }
let stateKey = "\(snapshot.runningName ?? "")|\(snapshot.extraRunningCount)|\(snapshot.isPremium)"
let now = Date.now
guard stateKey != lastComplicationStateKey
|| now.timeIntervalSince(lastComplicationPushAt) > 30 * 60 else { return }
lastComplicationStateKey = stateKey
lastComplicationPushAt = now
session.transferCurrentComplicationUserInfo([WatchSync.snapshotKey: data])
}
// MARK:
///
fileprivate func handle(command: String?, actionID: String?) -> [String: Any] {
let context = DataStore.shared.mainContext
if command == WatchSync.runCommand,
let idString = actionID,
let id = UUID(uuidString: idString) {
if PremiumGate.isUnlocked(.watchApp) {
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
if let action = actions.first(where: { $0.uuid == id }) {
watchSyncLog.notice("run command: \(action.name, privacy: .public)")
ActionRunner.run(action, context: context)
try? context.save()
LiveActivityManager.sync(context: context)
WidgetCenter.shared.reloadAllTimelines()
} else {
watchSyncLog.error("run command: action not found \(idString, privacy: .public)")
}
} else {
watchSyncLog.error("run command ignored: watchApp premium locked")
}
}
guard let data = makeSnapshot().encoded() else { return [:] }
return [WatchSync.snapshotKey: data]
}
}
extension WatchSyncManager: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?
) {
Task { @MainActor in
WatchSyncManager.shared.pushSnapshot()
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
/// / ( )
nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
let command = message[WatchSync.commandKey] as? String
let actionID = message[WatchSync.actionIDKey] as? String
Task { @MainActor in
let reply = WatchSyncManager.shared.handle(command: command, actionID: actionID)
replyHandler(reply)
WatchSyncManager.shared.pushSnapshot()
}
}
/// sendMessage ( )
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
let command = userInfo[WatchSync.commandKey] as? String
let actionID = userInfo[WatchSync.actionIDKey] as? String
watchSyncLog.notice("didReceiveUserInfo command=\(command ?? "nil", privacy: .public)")
guard command == WatchSync.runCommand else { return }
//
if let sentAt = userInfo[WatchSync.sentAtKey] as? TimeInterval,
Date.now.timeIntervalSince1970 - sentAt > WatchSync.queuedRunMaxAge {
return
}
Task { @MainActor in
_ = WatchSyncManager.shared.handle(command: command, actionID: actionID)
WatchSyncManager.shared.pushSnapshot()
}
}
}