refactor: decouple all UI preferences and layout settings from CloudKit sync to local device storage
- Add Shared/LocalPrefs.swift: device-local UI preferences stored in App Group UserDefaults (never synced by iCloud), read identically by app, widgets, watch snapshot, and App Intents on the same device - Move to local storage: main-tab action button order (UUID array), pinned goal-progress cards on the main tab, goal quest-list collapse state, and per-action memo-prompt popup — reordering or repinning on iPhone no longer touches the iPad and vice versa - Defense logic: actions not in the local order list (e.g. newly received via CloudKit from another device) are automatically appended to the end, sorted by legacy sortOrder then creation date - One-time migration adopts existing @Model values (sortOrder, showsOnMain, isCollapsed, promptsForNote) into local prefs at launch; model properties stay in place so the CloudKit schema is untouched and records keep syncing - Data remains fully synced: actions, goals, quests, sessions, count entries, statistics, and goal/tag/quest list order are untouched by this change - Verified in simulator: legacy data migrates losslessly, grid renders from local order, unknown action lands at the end; iOS+watch+widgets build clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
9e81cd3d03
commit
3dd53c4fc6
@ -27,9 +27,11 @@ enum DebugSeed {
|
||||
guard n > 0 else { return }
|
||||
let descriptor = FetchDescriptor<Goal>(sortBy: [SortDescriptor(\.createdAt)])
|
||||
guard let goals = try? context.fetch(descriptor) else { return }
|
||||
for (index, goal) in goals.enumerated() {
|
||||
goal.showsOnMain = index < n
|
||||
}
|
||||
// 노출 목표는 기기별 로컬 설정 (LocalPrefs 참고)
|
||||
LocalPrefs.defaults.set(
|
||||
LocalPrefs.rawValue(goals.prefix(n).map(\.uuid)),
|
||||
forKey: LocalPrefsKeys.pinnedGoals
|
||||
)
|
||||
}
|
||||
|
||||
static func seedIfRequested(context: ModelContext) {
|
||||
@ -220,6 +222,14 @@ enum DebugSeed {
|
||||
diet.status = .notAchieved
|
||||
diet.sortOrder = 5
|
||||
context.insert(diet)
|
||||
|
||||
// 기기별 로컬 UI 설정 시드: 메모 창 행동, 모음 탭 노출 목표 (LocalPrefs 참고)
|
||||
LocalPrefs.setPromptsForNote(reading.uuid, enabled: true)
|
||||
LocalPrefs.setPromptsForNote(water.uuid, enabled: true)
|
||||
LocalPrefs.defaults.set(
|
||||
LocalPrefs.rawValue([health.uuid]),
|
||||
forKey: LocalPrefsKeys.pinnedGoals
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -46,7 +46,10 @@ final class WatchSyncManager: NSObject {
|
||||
))) ?? []
|
||||
snapshot.tags = tags.map { WatchTagInfo(id: $0.uuid, name: $0.name, colorHex: $0.colorHex) }
|
||||
|
||||
let actions = (try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.sortOrder)]))) ?? []
|
||||
// 워치의 행동 나열 순서도 이 기기(페어링된 폰)의 모음 탭 배치(로컬 설정)를 따른다
|
||||
let actions = LocalPrefs.orderedActions(
|
||||
(try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
||||
)
|
||||
snapshot.actions = actions.map { action in
|
||||
let todayValue = agg.todayValue(for: action, now: now)
|
||||
let isCount = action.trackingType == .count
|
||||
|
||||
@ -21,6 +21,8 @@ struct Haru_DanimApp: App {
|
||||
SettingsMigration.runIfNeeded()
|
||||
container = DataStore.shared
|
||||
DataStore.ensureUniqueEntityIDs(context: container.mainContext)
|
||||
// 배치·노출 등 기기별 UI 설정: 구버전에 모델(@Model)로 저장하던 값을 로컬로 1회 이관
|
||||
LocalPrefs.adoptLegacyModelValuesIfNeeded(context: container.mainContext)
|
||||
// 시리 단축어/위젯 인텐트가 앱 프로세스에서 실행될 때 Live Activity·워치도 갱신되도록 주입
|
||||
IntentHooks.afterMutation = { context in
|
||||
LiveActivityManager.sync(context: context)
|
||||
|
||||
@ -13,13 +13,20 @@ import SwiftData
|
||||
struct ActionListView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
|
||||
@Query(sort: \Action.sortOrder) private var actions: [Action]
|
||||
@Query(sort: \Action.createdAt) private var allActions: [Action]
|
||||
private let premium = PremiumManager.shared
|
||||
|
||||
@State private var showingAdd = false
|
||||
@State private var showLimitAlert = false
|
||||
@State private var showingTagOrder = false
|
||||
|
||||
// 행동 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||||
|
||||
private var actions: [Action] {
|
||||
LocalPrefs.orderedActions(allActions, raw: actionOrderRaw)
|
||||
}
|
||||
|
||||
private var favoriteActions: [Action] {
|
||||
actions.filter(\.isFavorite)
|
||||
}
|
||||
@ -41,7 +48,7 @@ struct ActionListView: View {
|
||||
}
|
||||
}
|
||||
ForEach(tags) { tag in
|
||||
let group = tag.sortedActions.filter { !$0.isFavorite }
|
||||
let group = actions.filter { $0.tags.contains(tag) && !$0.isFavorite }
|
||||
if !group.isEmpty {
|
||||
Section {
|
||||
ForEach(group) { action in
|
||||
@ -220,7 +227,11 @@ struct ActionDetailView: View {
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: Bindable(action).promptsForNote) {
|
||||
// 메모 창 표시 여부는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||||
Toggle(isOn: Binding(
|
||||
get: { LocalPrefs.promptsForNote(action.uuid) },
|
||||
set: { LocalPrefs.setPromptsForNote(action.uuid, enabled: $0) }
|
||||
)) {
|
||||
Label {
|
||||
Text("기록 시 메모 창 표시")
|
||||
} icon: {
|
||||
@ -439,7 +450,7 @@ struct ActionEditorView: View {
|
||||
name = action.name
|
||||
symbolName = action.symbolName
|
||||
trackingType = action.trackingType
|
||||
promptsForNote = action.promptsForNote
|
||||
promptsForNote = LocalPrefs.promptsForNote(action.uuid)
|
||||
selectedTags = Set(action.tags.map(\.persistentModelID))
|
||||
}
|
||||
|
||||
@ -458,9 +469,10 @@ struct ActionEditorView: View {
|
||||
action.name = trimmed
|
||||
action.symbolName = symbolName
|
||||
action.trackingType = trackingType
|
||||
action.promptsForNote = promptsForNote
|
||||
action.tags = tags
|
||||
LocalPrefs.setPromptsForNote(action.uuid, enabled: promptsForNote)
|
||||
} else {
|
||||
// sortOrder는 로컬 배치 순서가 없는 기기(첫 실행·다른 기기)에서의 폴백 정렬용으로만 기록
|
||||
let maxOrder = allActions.map(\.sortOrder).max() ?? -1
|
||||
let newAction = Action(
|
||||
name: trimmed,
|
||||
@ -468,9 +480,11 @@ struct ActionEditorView: View {
|
||||
trackingType: trackingType,
|
||||
sortOrder: maxOrder + 1
|
||||
)
|
||||
newAction.promptsForNote = promptsForNote
|
||||
newAction.tags = tags
|
||||
context.insert(newAction)
|
||||
// 이 기기의 로컬 배치 맨 끝에 추가 + 메모 창 설정 저장
|
||||
LocalPrefs.appendActionToOrder(newAction.uuid)
|
||||
LocalPrefs.setPromptsForNote(newAction.uuid, enabled: promptsForNote)
|
||||
}
|
||||
DataChange.commit(context: context)
|
||||
dismiss()
|
||||
|
||||
@ -20,6 +20,9 @@ struct GoalListView: View {
|
||||
@State private var isReordering = false
|
||||
@State private var editMode: EditMode = .inactive
|
||||
|
||||
// 다짐 목록 접힘 상태는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.collapsedGoals, store: AppGroup.defaults) private var collapsedGoalsRaw = ""
|
||||
|
||||
/// 진행 중(확인 필요 포함) 목표만 기본 목록에 표시
|
||||
private var activeGoals: [Goal] { goals.filter { $0.status == .inProgress } }
|
||||
/// 달성/미달성으로 끝난 목표는 별도 화면으로 분리
|
||||
@ -63,7 +66,7 @@ struct GoalListView: View {
|
||||
} label: {
|
||||
GoalRow(goal: goal)
|
||||
}
|
||||
if !goal.isCollapsed {
|
||||
if !LocalPrefs.contains(goal.uuid, in: collapsedGoalsRaw) {
|
||||
ForEach(goal.sortedQuests) { quest in
|
||||
QuestRow(quest: quest)
|
||||
}
|
||||
@ -188,14 +191,15 @@ struct GoalListView: View {
|
||||
|
||||
/// 다짐 접기/펼치기 토글 헤더
|
||||
private func collapseHeader(_ goal: Goal) -> some View {
|
||||
Button {
|
||||
let collapsed = LocalPrefs.contains(goal.uuid, in: collapsedGoalsRaw)
|
||||
return Button {
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
goal.isCollapsed.toggle()
|
||||
collapsedGoalsRaw = LocalPrefs.toggling(goal.uuid, in: collapsedGoalsRaw)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("다짐 \(goal.quests.count)개")
|
||||
Image(systemName: goal.isCollapsed ? "chevron.down" : "chevron.up")
|
||||
Image(systemName: collapsed ? "chevron.down" : "chevron.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
@ -50,7 +50,14 @@ struct HistoryView: View {
|
||||
@Query private var sessions: [TimeSession]
|
||||
@Query private var entries: [CountEntry]
|
||||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||||
@Query(sort: \Action.sortOrder) private var allActions: [Action]
|
||||
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
||||
|
||||
// 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||||
|
||||
private var allActions: [Action] {
|
||||
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
|
||||
}
|
||||
|
||||
@State private var selectedDayKey: Date = DayMath().dayKey(for: .now)
|
||||
@State private var mode: HistoryMode = {
|
||||
|
||||
@ -11,13 +11,23 @@ import UniformTypeIdentifiers
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Action.sortOrder) private var actions: [Action]
|
||||
@Query(sort: \Action.createdAt) private var actions: [Action]
|
||||
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
|
||||
private var runningSessions: [TimeSession]
|
||||
@Query(
|
||||
filter: #Predicate<Goal> { $0.showsOnMain },
|
||||
sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]
|
||||
) private var pinnedGoals: [Goal]
|
||||
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)])
|
||||
private var allGoals: [Goal]
|
||||
|
||||
// 배치 순서·노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||||
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
||||
|
||||
private var orderedActions: [Action] {
|
||||
LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
|
||||
}
|
||||
|
||||
private var pinnedGoals: [Goal] {
|
||||
allGoals.filter { LocalPrefs.contains($0.uuid, in: pinnedGoalsRaw) }
|
||||
}
|
||||
|
||||
@Environment(AppRouter.self) private var router
|
||||
|
||||
@ -251,11 +261,11 @@ struct MainView: View {
|
||||
|
||||
private var grid: some View {
|
||||
LazyVGrid(columns: gridLayout, spacing: 12) {
|
||||
ForEach(actions) { action in
|
||||
ForEach(orderedActions) { action in
|
||||
cell(for: action)
|
||||
}
|
||||
}
|
||||
.animation(.default, value: actions.map(\.persistentModelID))
|
||||
.animation(.default, value: orderedActions.map(\.persistentModelID))
|
||||
.animation(.default, value: gridColumns)
|
||||
}
|
||||
|
||||
@ -358,7 +368,7 @@ struct MainView: View {
|
||||
let entry = CountEntry(action: action, timestamp: .now)
|
||||
context.insert(entry)
|
||||
DataChange.commit(context: context)
|
||||
if action.promptsForNote {
|
||||
if LocalPrefs.promptsForNote(action.uuid) {
|
||||
memoEntry = entry
|
||||
}
|
||||
}
|
||||
@ -371,7 +381,7 @@ struct MainView: View {
|
||||
context.delete(session)
|
||||
} else {
|
||||
session.endAt = .now
|
||||
if session.action?.promptsForNote == true {
|
||||
if let action = session.action, LocalPrefs.promptsForNote(action.uuid) {
|
||||
memoSession = session
|
||||
}
|
||||
}
|
||||
@ -384,14 +394,13 @@ struct MainView: View {
|
||||
|
||||
private func moveDragging(to target: Action) {
|
||||
guard let dragging = draggingAction, dragging != target else { return }
|
||||
var ordered = actions
|
||||
var ordered = orderedActions
|
||||
guard let from = ordered.firstIndex(of: dragging),
|
||||
let to = ordered.firstIndex(of: target) else { return }
|
||||
ordered.remove(at: from)
|
||||
ordered.insert(dragging, at: to)
|
||||
for (index, action) in ordered.enumerated() {
|
||||
action.sortOrder = index
|
||||
}
|
||||
// 배치는 기기별 로컬 설정 — CloudKit으로 동기화되는 모델(sortOrder)에는 쓰지 않는다
|
||||
actionOrderRaw = LocalPrefs.rawValue(ordered.map(\.uuid))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,9 +11,14 @@ import SwiftData
|
||||
struct QuestEditorView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query(sort: \Action.sortOrder) private var allActions: [Action]
|
||||
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
||||
@Query(sort: \Tag.createdAt) private var allTags: [Tag]
|
||||
|
||||
// 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||||
private var allActions: [Action] {
|
||||
LocalPrefs.orderedActions(allActionsQuery)
|
||||
}
|
||||
|
||||
let goal: Goal
|
||||
/// nil이면 새 다짐
|
||||
let quest: Quest?
|
||||
|
||||
@ -23,6 +23,8 @@ struct SettingsView: View {
|
||||
private let premium = PremiumManager.shared
|
||||
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
|
||||
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
|
||||
// 모음 탭 노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
||||
|
||||
private var visibleTabs: [AppTab] {
|
||||
AppTab.visibleTabs(from: visibleTabsRaw)
|
||||
@ -188,7 +190,7 @@ struct SettingsView: View {
|
||||
|
||||
private func pinnedGoalRow(_ goal: Goal) -> some View {
|
||||
Button {
|
||||
goal.showsOnMain.toggle()
|
||||
pinnedGoalsRaw = LocalPrefs.toggling(goal.uuid, in: pinnedGoalsRaw)
|
||||
} label: {
|
||||
HStack {
|
||||
Label {
|
||||
@ -200,7 +202,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(goal.color)
|
||||
}
|
||||
Spacer()
|
||||
if goal.showsOnMain {
|
||||
if LocalPrefs.contains(goal.uuid, in: pinnedGoalsRaw) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
@ -17,7 +17,14 @@ struct StatsTabView: View {
|
||||
@Query private var sessions: [TimeSession]
|
||||
@Query private var entries: [CountEntry]
|
||||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||||
@Query(sort: \Action.sortOrder) private var allActions: [Action]
|
||||
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
||||
|
||||
// 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||||
|
||||
private var allActions: [Action] {
|
||||
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
|
||||
}
|
||||
|
||||
@State private var span: StatSpan = {
|
||||
#if DEBUG
|
||||
|
||||
@ -43,7 +43,10 @@ enum IntentStore {
|
||||
}
|
||||
|
||||
static func actions() -> [Action] {
|
||||
(try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.sortOrder)]))) ?? []
|
||||
// 위젯·단축어의 행동 나열 순서도 이 기기의 모음 탭 배치(로컬 설정)를 따른다
|
||||
LocalPrefs.orderedActions(
|
||||
(try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
static func goals() -> [Goal] {
|
||||
|
||||
148
myApp/HaruDanim/Shared/LocalPrefs.swift
Normal file
148
myApp/HaruDanim/Shared/LocalPrefs.swift
Normal file
@ -0,0 +1,148 @@
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
|
||||
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: 구버전 @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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -86,10 +86,13 @@ final class Action {
|
||||
var name: String = ""
|
||||
var symbolName: String = "star.fill"
|
||||
var trackingTypeRaw: String = TrackingType.time.rawValue
|
||||
/// [레거시] 배치 순서는 기기별 로컬 설정(LocalPrefs.actionOrder)으로 이전.
|
||||
/// CloudKit 스키마 유지를 위해 남겨 두며, 로컬 순서가 없는 기기의 폴백 정렬로만 쓰인다.
|
||||
var sortOrder: Int = 0
|
||||
/// 행동 탭에서 최상단 "즐겨찾기" 섹션에 표시
|
||||
var isFavorite: Bool = false
|
||||
/// 기록 시(시간 측정 종료 / 횟수 추가) 메모 작성 창을 띄울지
|
||||
/// [레거시] 메모 창 표시 여부는 기기별 로컬 설정(LocalPrefs.notePromptActions)으로 이전.
|
||||
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
||||
var promptsForNote: Bool = false
|
||||
var createdAt: Date = Date()
|
||||
|
||||
@ -226,9 +229,11 @@ final class Goal {
|
||||
var startDate: Date = Date()
|
||||
var endDate: Date?
|
||||
var statusRaw: String = GoalStatus.inProgress.rawValue
|
||||
/// 목표 탭에서 하위 다짐 목록 접힘 여부
|
||||
/// [레거시] 접힘 상태는 기기별 로컬 설정(LocalPrefs.collapsedGoals)으로 이전.
|
||||
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
||||
var isCollapsed: Bool = false
|
||||
/// 모음 탭 상단에 진행 현황을 표시할 목표 (설정에서 다중 선택)
|
||||
/// [레거시] 노출 여부는 기기별 로컬 설정(LocalPrefs.pinnedGoals)으로 이전.
|
||||
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
||||
var showsOnMain: Bool = false
|
||||
/// 목표 탭 리스트 정렬 순서 (드래그로 변경, 값이 같으면 createdAt 순)
|
||||
var sortOrder: Int = 0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user