mycode/myApp/HaruDanim/Shared/LocalPrefs.swift
songyc macbook 8b1bc44f67 fix+feat(1.5-p7): 소킹 실측 2건 수정 + 추가 2건 — 빌드 5
- fix: 더보기 탭 일기 달력 날짜 탭 무반응 — morePath [AppTab]→NavigationPath(Date 푸시 가능), 회귀 인자 -morePushDiaryToday(착지 실측)
- fix: 수면 블록이 수면 단계별로 칸칸이 갈라짐 — 10분 이하 깸 병합(sleepDisplayMergeGap, 표시 전용·값 무영향)
- feat: 기록 탭 타임테이블(하루·주간)에 수면·운동 블록 — 단일 공급 지점·숨김 토글 공유, 내보내기 injectingHealthBlocks(forDayKeys:)+trim 확장
- feat: 타임테이블 표시 색 설정(설정→건강 데이터 — 기본 인디고·주황, 전 타임테이블·내보내기 공통), -healthColorPreview
- QA: 주간 6일 블록 렌더·커스텀 색 픽셀 검증(#FF2D55→(255,221,228))·색 설정 화면·Date 푸시 착지, l10n 0/0, Debug/Store 그린

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

324 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"
/// "#RRGGBB", =(). (1.5(4))
static let healthSleepColor = "local.healthSleepColor"
/// "#RRGGBB", =(). (1.5(4))
static let healthWorkoutColor = "local.healthWorkoutColor"
/// (· ) "-" , =(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
)
}
}
}