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:
songyc macbook 2026-07-11 20:58:34 +09:00
parent 9e81cd3d03
commit 3dd53c4fc6
13 changed files with 255 additions and 36 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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 = {

View File

@ -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))
}
}

View File

@ -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?

View File

@ -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)

View File

@ -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

View File

@ -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] {

View 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
)
}
}
}

View File

@ -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