feat(data): SwiftData 저장소를 App Group으로 이관하고 CloudKit 동기화 지원

- DataStore: App Group 컨테이너의 HaruDanim.store 사용, 기존 default.store 1회 자동 이관
- 프리미엄 + 동기화 토글 시 CloudKit(.private) 구성으로 컨테이너 생성, 실패하면 로컬 폴백
- CloudKit 요건(모든 관계 optional)에 맞춰 to-many 관계를 optional 저장 + 기존 이름
  접근자로 리팩터링 (originalName 매핑으로 기존 데이터 유지)
- Tag/Action/Goal/Quest에 uuid 추가 (시리 단축어·위젯·워치 식별자) + 중복 보정
- 모델 레이어(Models/Settings/DayMath/QuestProgress/Formatters/Theme)를 Shared/로 이동
  — 위젯 확장과 공유
- 집계 설정(주 시작 요일·하루 시작 시간·대표 시간·짧은 기록 무시)을 App Group defaults로
  이관해 위젯·워치와 공유, 설정의 '기기 간 동기화' 토글 활성화
- Goal.combinedSpanRatio 공용화 (모음 탭 카드·위젯·시리가 동일 산식 사용)
- DEBUG 검증 인자: -cloudSync YES, -settingsScrollPremium YES

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-10 08:59:15 +09:00
parent 26410006a6
commit f468fdf296
10 changed files with 256 additions and 48 deletions

View File

@ -22,7 +22,8 @@ enum LiveActivityManager {
let running = (try? context.fetch(descriptor)) ?? []
let mode = LiveActivityMode(
rawValue: UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
) ?? .latest
let representative = mode == .earliest ? running.first : running.last

View File

@ -22,7 +22,7 @@ struct MainView: View {
@Environment(AppRouter.self) private var router
@AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3
@AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0
@AppStorage(SettingsKeys.minSessionSeconds, store: AppGroup.defaults) private var minSessionSeconds = 0
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
@State private var isEditing: Bool = {
@ -482,12 +482,7 @@ struct GoalSummaryCard: View {
/// span ( 100% 0% )
private func combinedRatio(_ span: StatSpan) -> Double {
let quests = goal.sortedQuests
guard !quests.isEmpty else { return 0 }
let sum = quests.reduce(0.0) {
$0 + QuestProgress(quest: $1).spanProgress(span).ratio
}
return sum / Double(quests.count)
goal.combinedSpanRatio(span)
}
private func miniGauge(

View File

@ -12,11 +12,12 @@ struct SettingsView: View {
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
@AppStorage(SettingsKeys.theme) private var theme = "light"
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
@AppStorage(SettingsKeys.weekStartWeekday) private var weekStartWeekday = 2
@AppStorage(SettingsKeys.dayStartMinutes) private var dayStartMinutes = 0
@AppStorage(SettingsKeys.liveActivityMode) private var liveActivityMode = LiveActivityMode.latest.rawValue
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
@AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0
// · App Group defaults
@AppStorage(SettingsKeys.weekStartWeekday, store: AppGroup.defaults) private var weekStartWeekday = 2
@AppStorage(SettingsKeys.dayStartMinutes, store: AppGroup.defaults) private var dayStartMinutes = 0
@AppStorage(SettingsKeys.liveActivityMode, store: AppGroup.defaults) private var liveActivityMode = LiveActivityMode.latest.rawValue
@AppStorage(SettingsKeys.minSessionSeconds, store: AppGroup.defaults) private var minSessionSeconds = 0
private let premium = PremiumManager.shared
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
@ -122,12 +123,13 @@ struct SettingsView: View {
Label("프리미엄 기능", systemImage: "crown.fill")
.foregroundStyle(AppTheme.yellow)
Spacer()
Text(isPremium ? "사용 중" : "무료 사용 중")
Text(premium.isPremium ? "사용 중" : "무료 사용 중")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.id("premiumSection")
Section {
HStack {
Text("버전")
@ -140,13 +142,18 @@ struct SettingsView: View {
.background(AppTheme.background)
.navigationTitle("설정")
#if DEBUG
// : -settingsScrollGoal YES
// : -settingsScrollGoal YES , -settingsScrollPremium YES
.onAppear {
if UserDefaults.standard.bool(forKey: "settingsScrollGoal") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo("goalSection", anchor: .top) }
}
}
if UserDefaults.standard.bool(forKey: "settingsScrollPremium") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo("premiumSection", anchor: .top) }
}
}
}
#endif
}
@ -232,7 +239,8 @@ struct SettingsView: View {
// MARK: -
struct PremiumView: View {
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
private let premium = PremiumManager.shared
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
var body: some View {
List {
@ -246,13 +254,13 @@ struct PremiumView: View {
} footer: {
Text("무료: 행동 \(FreeLimits.actions)개 · 목표 \(FreeLimits.goals)개 · 목표당 다짐 \(FreeLimits.questsPerGoal)개까지")
}
if isPremium {
Section("동기화") {
Toggle("기기 간 동기화", isOn: .constant(false))
.disabled(true)
Text("iCloud 동기화는 준비 중이에요.")
.font(.caption)
.foregroundStyle(.secondary)
if premium.isPremium {
Section {
Toggle("기기 간 동기화", isOn: $cloudSync)
} header: {
Text("동기화")
} footer: {
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
}
} else {
Section {
@ -268,7 +276,10 @@ struct PremiumView: View {
}
#if DEBUG
Section("개발용") {
Toggle("프리미엄 상태 (테스트)", isOn: $isPremium)
Toggle("프리미엄 상태 (테스트)", isOn: Binding(
get: { premium.isPremium },
set: { premium.setMockPremium($0) }
))
}
#endif
}

View File

@ -0,0 +1,143 @@
//
// DataStore.swift
// Haru_Danim
//
// SwiftData (CLAUDE.md §2.2)
// - : App Group (·App Intents DB )
// - default.store 1 App Group
// - iCloud (): CloudKit ,
//
import Foundation
import SwiftData
nonisolated enum DataStore {
/// .
/// App Intents .
static let shared: ModelContainer = makeContainer()
static let schema = Schema([
Tag.self,
Action.self,
TimeSession.self,
CountEntry.self,
Goal.self,
Quest.self,
])
/// iCloud on/off (, App Group defaults )
static let cloudSyncKey = "settings.cloudSync"
static let cloudContainerID = "iCloud.com.yechan.HaruDanim"
/// App Group
static var storeURL: URL {
guard let base = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroup.identifier
) else {
// App Group ( )
return URL.applicationSupportDirectory.appending(path: "default.store")
}
return base.appending(path: "HaruDanim.store")
}
/// iCloud ( + on)
static var wantsCloudSync: Bool {
#if DEBUG
if UserDefaults.standard.object(forKey: "cloudSync") != nil {
return UserDefaults.standard.bool(forKey: "cloudSync")
}
#endif
return PremiumGate.isUnlocked(.cloudSync) && AppGroup.defaults.bool(forKey: cloudSyncKey)
}
/// ··
static func makeContainer() -> ModelContainer {
migrateLegacyStoreIfNeeded()
if wantsCloudSync {
do {
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .private(cloudContainerID)
)
return try ModelContainer(for: schema, configurations: [config])
} catch {
// iCloud /
print("[DataStore] CloudKit 컨테이너 생성 실패, 로컬로 폴백: \(error)")
}
}
do {
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("[DataStore] 컨테이너 생성 실패: \(error)")
}
}
/// uuid : UUID
/// ( · , 1 )
@MainActor
static func ensureUniqueEntityIDs(context: ModelContext) {
var changed = false
var tagIDs = Set<UUID>()
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] where !tagIDs.insert(tag.uuid).inserted {
tag.uuid = UUID()
changed = true
}
var actionIDs = Set<UUID>()
for action in (try? context.fetch(FetchDescriptor<Action>())) ?? [] where !actionIDs.insert(action.uuid).inserted {
action.uuid = UUID()
changed = true
}
var goalIDs = Set<UUID>()
for goal in (try? context.fetch(FetchDescriptor<Goal>())) ?? [] where !goalIDs.insert(goal.uuid).inserted {
goal.uuid = UUID()
changed = true
}
var questIDs = Set<UUID>()
for quest in (try? context.fetch(FetchDescriptor<Quest>())) ?? [] where !questIDs.insert(quest.uuid).inserted {
quest.uuid = UUID()
changed = true
}
if changed { try? context.save() }
}
/// (Application Support/default.store) DB App Group 1
private static func migrateLegacyStoreIfNeeded() {
let fm = FileManager.default
let target = storeURL
guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path,
!fm.fileExists(atPath: target.path) else { return }
let legacy = URL.applicationSupportDirectory.appending(path: "default.store")
guard fm.fileExists(atPath: legacy.path) else { return }
// -wal/-shm
for suffix in ["", "-shm", "-wal"] {
let from = URL(fileURLWithPath: legacy.path + suffix)
let to = URL(fileURLWithPath: target.path + suffix)
if fm.fileExists(atPath: from.path) {
try? fm.copyItem(at: from, to: to)
}
}
}
}
// MARK: - App Group
/// · ( , , )
/// UserDefaults.standard App Group defaults 1
nonisolated enum SettingsMigration {
private static let doneKey = "migration.settingsToAppGroup"
static func runIfNeeded() {
let group = AppGroup.defaults
guard !group.bool(forKey: doneKey) else { return }
let standard = UserDefaults.standard
for key in [SettingsKeys.weekStartWeekday, SettingsKeys.dayStartMinutes,
SettingsKeys.liveActivityMode, SettingsKeys.minSessionSeconds] {
if group.object(forKey: key) == nil, let value = standard.object(forKey: key) {
group.set(value, forKey: key)
}
}
group.set(true, forKey: doneKey)
}
}

View File

@ -36,16 +36,31 @@ enum TrackingType: String, CaseIterable, Identifiable {
@Model
final class Tag {
/// ID
var uuid: UUID = UUID()
var name: String = ""
var colorHex: String = "#2F6B4F"
/// /
var sortOrder: Int = 0
var createdAt: Date = Date()
var actions: [Action] = []
// CloudKit : optional optional,
// non-optional (originalName )
@Relationship(originalName: "actions")
var actionsStorage: [Action]? = []
@Relationship(deleteRule: .nullify, inverse: \Quest.targetTag)
var quests: [Quest] = []
@Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetTag)
var questsStorage: [Quest]? = []
var actions: [Action] {
get { actionsStorage ?? [] }
set { actionsStorage = newValue }
}
var quests: [Quest] {
get { questsStorage ?? [] }
set { questsStorage = newValue }
}
init(name: String, colorHex: String) {
self.name = name
@ -66,6 +81,8 @@ extension Tag {
@Model
final class Action {
/// · ID
var uuid: UUID = UUID()
var name: String = ""
var symbolName: String = "star.fill"
var trackingTypeRaw: String = TrackingType.time.rawValue
@ -76,17 +93,38 @@ final class Action {
var promptsForNote: Bool = false
var createdAt: Date = Date()
@Relationship(inverse: \Tag.actions)
var tags: [Tag] = []
// CloudKit optional + non-optional (originalName )
@Relationship(originalName: "tags", inverse: \Tag.actionsStorage)
var tagsStorage: [Tag]? = []
@Relationship(deleteRule: .cascade, inverse: \TimeSession.action)
var sessions: [TimeSession] = []
@Relationship(deleteRule: .cascade, originalName: "sessions", inverse: \TimeSession.action)
var sessionsStorage: [TimeSession]? = []
@Relationship(deleteRule: .cascade, inverse: \CountEntry.action)
var countEntries: [CountEntry] = []
@Relationship(deleteRule: .cascade, originalName: "countEntries", inverse: \CountEntry.action)
var countEntriesStorage: [CountEntry]? = []
@Relationship(deleteRule: .nullify, inverse: \Quest.targetAction)
var quests: [Quest] = []
@Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetAction)
var questsStorage: [Quest]? = []
var tags: [Tag] {
get { tagsStorage ?? [] }
set { tagsStorage = newValue }
}
var sessions: [TimeSession] {
get { sessionsStorage ?? [] }
set { sessionsStorage = newValue }
}
var countEntries: [CountEntry] {
get { countEntriesStorage ?? [] }
set { countEntriesStorage = newValue }
}
var quests: [Quest] {
get { questsStorage ?? [] }
set { questsStorage = newValue }
}
init(name: String, symbolName: String, trackingType: TrackingType, sortOrder: Int) {
self.name = name
@ -180,6 +218,8 @@ enum GoalStatus: String, CaseIterable {
@Model
final class Goal {
/// · ID
var uuid: UUID = UUID()
var title: String = ""
var symbolName: String = "flag.fill"
var colorHex: String = "#2F6B4F"
@ -194,8 +234,14 @@ final class Goal {
var sortOrder: Int = 0
var createdAt: Date = Date()
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
var quests: [Quest] = []
// CloudKit optional + non-optional (originalName )
@Relationship(deleteRule: .cascade, originalName: "quests", inverse: \Quest.goal)
var questsStorage: [Quest]? = []
var quests: [Quest] {
get { questsStorage ?? [] }
set { questsStorage = newValue }
}
init(title: String, symbolName: String, colorHex: String, startDate: Date, endDate: Date?) {
self.title = title
@ -299,6 +345,8 @@ enum QuestDirection: String, CaseIterable, Identifiable {
@Model
final class Quest {
/// · ID
var uuid: UUID = UUID()
var goal: Goal?
/// :

View File

@ -180,6 +180,17 @@ struct QuestProgress {
// MARK: -
extension Goal {
/// span ( 100% 0% ).
/// ·· .
func combinedSpanRatio(_ span: StatSpan, math: DayMath = DayMath(), now: Date = .now) -> Double {
let quests = sortedQuests
guard !quests.isEmpty else { return 0 }
let sum = quests.reduce(0.0) {
$0 + QuestProgress(quest: $1, math: math).spanProgress(span, now: now).ratio
}
return sum / Double(quests.count)
}
/// .
/// ( )
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {

View File

@ -18,8 +18,7 @@ enum SettingsKeys {
static let dayStartMinutes = "settings.dayStartMinutes"
/// "latest"( ) | "earliest"( ). latest
static let liveActivityMode = "settings.liveActivityMode"
///
static let isPremium = "settings.isPremium"
// Shared/Premium.swift PremiumGate.cacheKey (App Group defaults)
/// . 3
static let gridColumns = "settings.gridColumns"
/// () . 0 =
@ -94,12 +93,7 @@ enum AppLanguage: String, CaseIterable, Identifiable {
}
}
/// (CLAUDE.md §7.1)
enum FreeLimits {
static let actions = 10
static let goals = 2
static let questsPerGoal = 3
}
// (FreeLimits) Shared/Premium.swift (· )
///
struct TrackingSettings {
@ -107,10 +101,15 @@ struct TrackingSettings {
var dayStartMinutes: Int
static func current() -> TrackingSettings {
let defaults = UserDefaults.standard
// App Group (· ), standard
let group = AppGroup.defaults
let standard = UserDefaults.standard
func intValue(_ key: String, default defaultValue: Int) -> Int {
(group.object(forKey: key) ?? standard.object(forKey: key)) as? Int ?? defaultValue
}
return TrackingSettings(
weekStartWeekday: defaults.object(forKey: SettingsKeys.weekStartWeekday) as? Int ?? 2,
dayStartMinutes: defaults.object(forKey: SettingsKeys.dayStartMinutes) as? Int ?? 0
weekStartWeekday: intValue(SettingsKeys.weekStartWeekday, default: 2),
dayStartMinutes: intValue(SettingsKeys.dayStartMinutes, default: 0)
)
}