- 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
324 lines
16 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|