mycode/myApp/HaruDanim/Shared/DataStore.swift
songyc macbook f468fdf296 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
2026-07-10 08:59:15 +09:00

144 lines
5.9 KiB
Swift

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