즐겨찾기 빠른 실행 줄(7d17917)이 그리드와 셀 크기·배경이 달라 '따로 노는 느낌'이고 같은 행동이 두 번 보인다는 실사용 피드백 반영. 도입 의도(행동이 많아져도 자주 쓰는 것만 모아 보기)는 유지하고 형태만 교체. - 즐겨찾기를 별도 카드가 아니라 그리드의 첫 섹션으로: 통짜 그리드와 같은 actionGrid(_:) 빌더를 공유해 셀 크기·열 규칙·맥 폭 보정이 항상 동일(v1의 컴팩트 셀·맥 특례 폭 제거). 중복 노출도 사라짐 - '나머지 행동 · N개' 섹션은 헤더 탭으로 접기/펼치기(기기별 local.mainOthersCollapsed, 기본 펼침, 셰브론 회전) — 행동이 많아도 접으면 즐겨찾기만 남는다. 숨기는 행동은 없음(위치만 승격, 그리드 완전 숨김 방식 기각은 유지) - 편집(배치) 중에는 기존처럼 통짜 그리드(배치 순서는 하나뿐) - 시드 즐겨찾기 1→3개(독서·달리기·물 마시기), -mainOthersCollapsed DEBUG 인자 추가(접힘 표시 강제 — 표시 전용, 실 설정 무변경) - 도움말 '즐겨찾기 모아 보기'로 개편 + en/ja 번역, stale 2키 정리 - 검증: Debug/Store 빌드, 아이폰 기본·접힘·편집 + 아이패드 + 맥 강제 레이아웃 스크린샷 5종, 카탈로그 missing/stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
7.6 KiB
Swift
167 lines
7.6 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"
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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: 구버전 @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
|
|
)
|
|
}
|
|
}
|
|
}
|