From f468fdf296fddc2272a8291031e9d835b2e0c5f2 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 08:59:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(data):=20SwiftData=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20App=20Group=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80=ED=95=98=EA=B3=A0=20CloudKit=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../IOS/Core/LiveActivityManager.swift | 3 +- myApp/HaruDanim/IOS/Views/MainView.swift | 9 +- myApp/HaruDanim/IOS/Views/SettingsView.swift | 43 ++++-- myApp/HaruDanim/Shared/DataStore.swift | 143 ++++++++++++++++++ .../{IOS/Core => Shared}/DayMath.swift | 0 .../{IOS/Core => Shared}/Formatters.swift | 0 .../{IOS/Core => Shared}/Models.swift | 74 +++++++-- .../{IOS/Core => Shared}/QuestProgress.swift | 11 ++ .../{IOS/Core => Shared}/Settings.swift | 21 ++- .../{IOS/Core => Shared}/Theme.swift | 0 10 files changed, 256 insertions(+), 48 deletions(-) create mode 100644 myApp/HaruDanim/Shared/DataStore.swift rename myApp/HaruDanim/{IOS/Core => Shared}/DayMath.swift (100%) rename myApp/HaruDanim/{IOS/Core => Shared}/Formatters.swift (100%) rename myApp/HaruDanim/{IOS/Core => Shared}/Models.swift (81%) rename myApp/HaruDanim/{IOS/Core => Shared}/QuestProgress.swift (92%) rename myApp/HaruDanim/{IOS/Core => Shared}/Settings.swift (82%) rename myApp/HaruDanim/{IOS/Core => Shared}/Theme.swift (100%) diff --git a/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift b/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift index 2930e2d..15add92 100644 --- a/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift +++ b/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift @@ -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 diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index ed07551..7321255 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -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( diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 9dc0c9e..5d3a229 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -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 } diff --git a/myApp/HaruDanim/Shared/DataStore.swift b/myApp/HaruDanim/Shared/DataStore.swift new file mode 100644 index 0000000..cfc8eec --- /dev/null +++ b/myApp/HaruDanim/Shared/DataStore.swift @@ -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() + for tag in (try? context.fetch(FetchDescriptor())) ?? [] where !tagIDs.insert(tag.uuid).inserted { + tag.uuid = UUID() + changed = true + } + var actionIDs = Set() + for action in (try? context.fetch(FetchDescriptor())) ?? [] where !actionIDs.insert(action.uuid).inserted { + action.uuid = UUID() + changed = true + } + var goalIDs = Set() + for goal in (try? context.fetch(FetchDescriptor())) ?? [] where !goalIDs.insert(goal.uuid).inserted { + goal.uuid = UUID() + changed = true + } + var questIDs = Set() + for quest in (try? context.fetch(FetchDescriptor())) ?? [] 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) + } +} diff --git a/myApp/HaruDanim/IOS/Core/DayMath.swift b/myApp/HaruDanim/Shared/DayMath.swift similarity index 100% rename from myApp/HaruDanim/IOS/Core/DayMath.swift rename to myApp/HaruDanim/Shared/DayMath.swift diff --git a/myApp/HaruDanim/IOS/Core/Formatters.swift b/myApp/HaruDanim/Shared/Formatters.swift similarity index 100% rename from myApp/HaruDanim/IOS/Core/Formatters.swift rename to myApp/HaruDanim/Shared/Formatters.swift diff --git a/myApp/HaruDanim/IOS/Core/Models.swift b/myApp/HaruDanim/Shared/Models.swift similarity index 81% rename from myApp/HaruDanim/IOS/Core/Models.swift rename to myApp/HaruDanim/Shared/Models.swift index 0688831..b5e28ed 100644 --- a/myApp/HaruDanim/IOS/Core/Models.swift +++ b/myApp/HaruDanim/Shared/Models.swift @@ -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? /// 대상: 행동 또는 꼬리표 중 하나 diff --git a/myApp/HaruDanim/IOS/Core/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift similarity index 92% rename from myApp/HaruDanim/IOS/Core/QuestProgress.swift rename to myApp/HaruDanim/Shared/QuestProgress.swift index 025b205..05e8a73 100644 --- a/myApp/HaruDanim/IOS/Core/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -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) { diff --git a/myApp/HaruDanim/IOS/Core/Settings.swift b/myApp/HaruDanim/Shared/Settings.swift similarity index 82% rename from myApp/HaruDanim/IOS/Core/Settings.swift rename to myApp/HaruDanim/Shared/Settings.swift index 1b98977..54a7ad6 100644 --- a/myApp/HaruDanim/IOS/Core/Settings.swift +++ b/myApp/HaruDanim/Shared/Settings.swift @@ -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) ) } diff --git a/myApp/HaruDanim/IOS/Core/Theme.swift b/myApp/HaruDanim/Shared/Theme.swift similarity index 100% rename from myApp/HaruDanim/IOS/Core/Theme.swift rename to myApp/HaruDanim/Shared/Theme.swift