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:
parent
26410006a6
commit
f468fdf296
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
143
myApp/HaruDanim/Shared/DataStore.swift
Normal file
143
myApp/HaruDanim/Shared/DataStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
|
||||
/// 대상: 행동 또는 꼬리표 중 하나
|
||||
@ -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) {
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user