mycode/myApp/HaruDanim/Shared/LocalPrefs.swift
songyc macbook 0a55b45515 feat(1.5-p6): 소킹 중 추가 5건 — 아이폰 일기 탭·일기 건강 카드·타임테이블 수면/운동 실구간·수면 표시 구간(요일별)·맥 필터 안내, 빌드 4
- 아이폰 일기 개방: AppTab.phoneCases·radialOrder, '그리기' 토글(손가락)·가로 도구줄, 일기 잠금 설정 전 기기
- 건강 카드: DiarySection.health(목록 끝), DiaryHealthCard(지표 선택 공용화·타일 상속·맥 숨김), 인쇄 요약 동승
- 타임테이블 실구간: HealthIntervals 캐시(App Group 400일, CloudKit 금지), injectingHealthBlocks(수면 인디고·운동 주황), 필터 '건강 데이터' 토글+맥 안내
- 수면 표시 구간: SleepWindowPrefs(전체+요일별), 평문 sleep 키=유효 구간 값, 백필 서명 매핑 포함, 새 수면 다짐 기본값 시드
- 실측 수정: 일기 목표 카드 건강 다짐 값 단위, ja 건강 칩 말줄임
- 검증 61/20/27/10 ALL PASS·3종 빌드·카탈로그 0/0·시각 QA 10여 장(26.5/18.5/맥 분기)
- 마케팅: 06-bubble 재촬영, 10-week→10-diary 교체, 아이패드 03 재촬영(3언어), whats-new/설명 갱신

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

320 lines
16 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"
/// Bool, (1.5. true )
static let healthTilesEnabled = "local.healthTilesEnabled"
/// 3(··) MainSectionKind raw (1.5)
static let mainSectionOrder = "local.mainSectionOrder"
/// Bool (1.5)
static let healthCollapsed = "local.healthCollapsed"
/// HealthMetric raw ( , 1.5).
/// : ··
static let healthMetrics = "local.healthMetrics"
/// (· ) "-" , =(1260-540) (1.5)
static let sleepWindowGlobal = "local.sleepWindowGlobal"
/// Bool (1.5)
static let sleepWindowWeekdayEnabled = "local.sleepWindowWeekdayEnabled"
/// ["1"()~"7"(): ] . (1.5)
static let sleepWindowByWeekday = "local.sleepWindowByWeekday"
}
// MARK: - (1.5 , · )
/// ' '(
/// ) . .
/// (App Group defaults) CloudKit .
nonisolated enum SleepWindowPrefs {
static let defaultSpec = "1260-540"
/// ( )
static var globalSpec: String {
let raw = AppGroup.defaults.string(forKey: LocalPrefsKeys.sleepWindowGlobal) ?? ""
return raw.isEmpty ? defaultSpec : raw
}
static var weekdayEnabled: Bool {
AppGroup.defaults.bool(forKey: LocalPrefsKeys.sleepWindowWeekdayEnabled)
}
static var weekdaySpecs: [String: String] {
(AppGroup.defaults.dictionary(forKey: LocalPrefsKeys.sleepWindowByWeekday) as? [String: String]) ?? [:]
}
/// (1= ~ 7=)
static func spec(forWeekday weekday: Int) -> String {
guard weekdayEnabled, let raw = weekdaySpecs["\(weekday)"], !raw.isEmpty else { return globalSpec }
return raw
}
/// ( )
static func spec(forDayKey dayKey: Date) -> String {
spec(forWeekday: Calendar.current.component(.weekday, from: dayKey))
}
/// ( + )
static var allSpecs: Set<String> {
var specs: Set<String> = [globalSpec]
if weekdayEnabled {
for raw in weekdaySpecs.values where !raw.isEmpty { specs.insert(raw) }
}
return specs
}
/// ,
/// (: ) "sleep"
static var mappingSignature: String {
(1...7).map { spec(forWeekday: $0) }.joined(separator: "/")
}
}
/// 3 (1.5 · , )
nonisolated enum MainSectionKind: String, CaseIterable, Identifiable {
case favorites, others, health
var id: String { rawValue }
var label: String {
switch self {
case .favorites: return String(localized: "즐겨찾기 행동")
case .others: return String(localized: "나머지 행동")
case .health: return String(localized: "건강 데이터")
}
}
/// ( )
static func orderedList(raw: String) -> [MainSectionKind] {
var result = raw.split(separator: ",").compactMap { MainSectionKind(rawValue: String($0)) }
for kind in MainSectionKind.allCases where !result.contains(kind) {
result.append(kind)
}
return result
}
static func saveOrder(_ kinds: [MainSectionKind]) {
LocalPrefs.defaults.set(kinds.map(\.rawValue).joined(separator: ","), forKey: LocalPrefsKeys.mainSectionOrder)
}
}
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
)
}
}
}