mycode/myApp/HaruDanim/Shared/LocalPrefs.swift
songyc macbook 92afa69f6d feat(1.5-p1): 워치 '행동 기록' 컴플리케이션 — 기간 옵트인·미니 행동 화면 딥링크
- WatchActionPeriod(하루/이번 주/이번 달/지난 7일/지난 30일) + WatchActionInfo에
  optional 기간 필드 3종(구버전 캐시 호환 — isFavorite 패턴)
- LocalPrefs.watchActions(uuid→기간 맵, 기기 로컬) + 전체 초기화 청소 목록 반영
- makeSnapshot: 옵트인 행동만 기간값 계산(rollingRange 재사용, StatSpan 무변경)
- 신규 ActionValueComplication(4패밀리·측정 중 노랑+타이머·유령 타이머 안전장치·
  빈 상태 안내·recommendations verbatim·widgetURL 딥링크)
- 워치 미니 행동 화면(WatchActionDetailView) + NavigationPath 딥링크 착지
  (뒤로가기 한 번에 메인 복귀), 행동 편집기 애플워치 섹션(토글+기간)
- 검증 인자: -watchActions <N>·-actionShowEditor·-watchOpenActionDetail
- 검증: Debug/Store/워치(26.2·11.5) 빌드, 자가 검증 49·20·27·10 ALL PASS(26.5+18.5 심),
  시각 QA 5장. 신규 문구 l10n은 계획 §10 R6대로 도움말 단계에서 일괄
- CLAUDE.md: 시뮬레이터 세트 리셋 반영(신규 UUID)·검증 인자 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 05:18:42 +09:00

229 lines
12 KiB
Swift

//
// LocalPrefs.swift
// Haru_Danim
//
// UI (CloudKit )
//
// : ·· "" CloudKit ,
// " "( , , , )
// App Group UserDefaults iCloud .
// · ·App Intents App Group defaults
// .
//
// @Model (Action.sortOrder, Action.promptsForNote, Goal.showsOnMain,
// Goal.isCollapsed) CloudKit ( ),
// UI . 1 .
//
import Foundation
import SwiftData
nonisolated enum LocalPrefsKeys {
/// Action.uuid
static let actionOrder = "local.actionOrder"
/// Goal.uuid
static let pinnedGoals = "local.pinnedGoals"
/// Goal.uuid
static let collapsedGoals = "local.collapsedGoals"
/// Action.uuid
static let notePromptActions = "local.notePromptActions"
/// Goal.uuid .
/// ( ) · .
/// .
static let watchGoals = "local.watchGoals"
/// ' ' ( ) Bool
static let mainOthersCollapsed = "local.mainOthersCollapsed"
/// ' ' (1.5)
/// [Action.uuid : WatchActionPeriod raw] . ().
/// (watchGoals )
static let watchActions = "local.watchActions"
}
nonisolated enum LocalPrefs {
static var defaults: UserDefaults { AppGroup.defaults }
// MARK: ( UUID )
static func idList(_ raw: String) -> [UUID] {
raw.split(separator: ",").compactMap { UUID(uuidString: String($0)) }
}
static func rawValue(_ ids: [UUID]) -> String {
ids.map(\.uuidString).joined(separator: ",")
}
static func contains(_ id: UUID, in raw: String) -> Bool {
raw.contains(id.uuidString)
}
/// ( , , ) id raw
static func toggling(_ id: UUID, in raw: String) -> String {
var ids = idList(raw)
if let index = ids.firstIndex(of: id) {
ids.remove(at: index)
} else {
ids.append(id)
}
return rawValue(ids)
}
// MARK:
/// .
/// : ( CloudKit )
/// . () sortOrder .
static func orderedActions(_ actions: [Action], raw: String) -> [Action] {
var position: [UUID: Int] = [:]
for (index, id) in idList(raw).enumerated() {
position[id] = index
}
return actions.sorted {
switch (position[$0.uuid], position[$1.uuid]) {
case let (a?, b?): return a < b
case (.some, nil): return true
case (nil, .some): return false
case (nil, nil):
if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder }
return $0.createdAt < $1.createdAt
}
}
}
static func orderedActions(_ actions: [Action]) -> [Action] {
orderedActions(actions, raw: defaults.string(forKey: LocalPrefsKeys.actionOrder) ?? "")
}
/// ( )
static func saveActionOrder(_ actions: [Action]) {
defaults.set(rawValue(actions.map(\.uuid)), forKey: LocalPrefsKeys.actionOrder)
}
/// .
/// ( )
/// (sortOrder) .
static func appendActionToOrder(_ id: UUID) {
guard let raw = defaults.string(forKey: LocalPrefsKeys.actionOrder) else { return }
var ids = idList(raw)
guard !ids.contains(id) else { return }
ids.append(id)
defaults.set(rawValue(ids), forKey: LocalPrefsKeys.actionOrder)
}
/// '' : .
/// " "
/// ( ),
/// ( ) (§6.1 v2)
static func placeNewFavoriteAtEnd(_ action: Action, context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<Action>())) ?? []
var ordered = orderedActions(all)
guard let from = ordered.firstIndex(where: { $0.uuid == action.uuid }) else { return }
let moved = ordered.remove(at: from)
guard let lastFavorite = ordered.lastIndex(where: { $0.isFavorite }) else { return }
ordered.insert(moved, at: lastFavorite + 1)
saveActionOrder(ordered)
}
/// ( )
/// '' , .
/// ( N)
/// . · (§6.1 v2·§11)
static func reorderFavorites(from source: IndexSet, to destination: Int, context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<Action>())) ?? []
var ordered = orderedActions(all)
let slots = ordered.indices.filter { ordered[$0].isFavorite }
var favorites = slots.map { ordered[$0] }
// move(fromOffsets:toOffset:) SwiftUI (Foundation )
// (destination )
let moving = source.sorted(by: >).map { favorites.remove(at: $0) }.reversed()
let insertAt = destination - source.count(where: { $0 < destination })
favorites.insert(contentsOf: moving, at: insertAt)
for (slot, action) in zip(slots, favorites) {
ordered[slot] = action
}
saveActionOrder(ordered)
}
// MARK: ( )
static func promptsForNote(_ id: UUID) -> Bool {
contains(id, in: defaults.string(forKey: LocalPrefsKeys.notePromptActions) ?? "")
}
static func setPromptsForNote(_ id: UUID, enabled: Bool) {
let raw = defaults.string(forKey: LocalPrefsKeys.notePromptActions) ?? ""
let current = contains(id, in: raw)
guard current != enabled else { return }
defaults.set(toggling(id, in: raw), forKey: LocalPrefsKeys.notePromptActions)
}
// MARK: ( )
static func showsOnWatch(_ id: UUID) -> Bool {
contains(id, in: defaults.string(forKey: LocalPrefsKeys.watchGoals) ?? "")
}
static func setShowsOnWatch(_ id: UUID, enabled: Bool) {
let raw = defaults.string(forKey: LocalPrefsKeys.watchGoals) ?? ""
guard contains(id, in: raw) != enabled else { return }
defaults.set(toggling(id, in: raw), forKey: LocalPrefsKeys.watchGoals)
}
// MARK: ' ' (1.5 + , )
/// [uuid: raw] ( )
static func watchActionPeriods() -> [UUID: String] {
let raw = (defaults.dictionary(forKey: LocalPrefsKeys.watchActions) as? [String: String]) ?? [:]
var result: [UUID: String] = [:]
for (key, value) in raw {
if let id = UUID(uuidString: key) { result[id] = value }
}
return result
}
static func watchActionPeriodRaw(_ id: UUID) -> String? {
(defaults.dictionary(forKey: LocalPrefsKeys.watchActions) as? [String: String])?[id.uuidString]
}
/// raw nil ( ).
static func setWatchActionPeriod(_ id: UUID, raw: String?) {
var dict = (defaults.dictionary(forKey: LocalPrefsKeys.watchActions) as? [String: String]) ?? [:]
guard dict[id.uuidString] != raw else { return }
dict[id.uuidString] = raw
defaults.set(dict, forKey: LocalPrefsKeys.watchActions)
}
// MARK: @Model 1
/// , ( ).
/// (: CloudKit )
/// .
@MainActor
static func adoptLegacyModelValuesIfNeeded(context: ModelContext) {
let actions = (try? context.fetch(FetchDescriptor<Action>(
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
if defaults.string(forKey: LocalPrefsKeys.actionOrder) == nil, !actions.isEmpty {
saveActionOrder(actions)
}
if defaults.string(forKey: LocalPrefsKeys.notePromptActions) == nil, !actions.isEmpty {
defaults.set(
rawValue(actions.filter(\.promptsForNote).map(\.uuid)),
forKey: LocalPrefsKeys.notePromptActions
)
}
let goals = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
if defaults.string(forKey: LocalPrefsKeys.pinnedGoals) == nil, !goals.isEmpty {
defaults.set(
rawValue(goals.filter(\.showsOnMain).map(\.uuid)),
forKey: LocalPrefsKeys.pinnedGoals
)
}
if defaults.string(forKey: LocalPrefsKeys.collapsedGoals) == nil, !goals.isEmpty {
defaults.set(
rawValue(goals.filter(\.isCollapsed).map(\.uuid)),
forKey: LocalPrefsKeys.collapsedGoals
)
}
}
}