// // 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())) ?? [] 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())) ?? [] 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( 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())) ?? [] 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 ) } } }