diff --git a/myApp/HaruDanim/IOS/Core/DataChange.swift b/myApp/HaruDanim/IOS/Core/DataChange.swift new file mode 100644 index 0000000..18f8366 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/DataChange.swift @@ -0,0 +1,23 @@ +// +// DataChange.swift +// Haru_Danim +// +// 앱 안에서 데이터가 바뀌는 모든 Mutation의 끝단에서 호출하는 공통 갱신 지점. +// 주기적 백그라운드 새로 고침 대신 변경 이벤트 시점에만 위젯 타임라인을 갱신해 +// WidgetKit 업데이트 예산을 아낀다. (시리/위젯 인텐트 경로는 IntentStore.commit()이 같은 역할) +// + +import SwiftData +import WidgetKit + +@MainActor +enum DataChange { + /// 저장을 즉시 확정(위젯 확장이 같은 App Group 저장소를 읽으므로 autosave를 기다리지 않음)하고 + /// Live Activity → 위젯 타임라인 → 워치 스냅숏 순으로 동기화한다. + static func commit(context: ModelContext) { + try? context.save() + LiveActivityManager.sync(context: context) + WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() + } +} diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 3be83e4..b0d3ef2 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -1,6 +1,162 @@ { "sourceLanguage" : "ko", "strings" : { + "'%@ · %@' %@ 진행률: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@ · %@' %@ progress: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@ · %@」の%@進捗率: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "'%1$@ · %2$@' %3$@ 진행률: %4$@" + } + } + } + }, + "'%@' %@ 진행률은 %lld%%예요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' %@ progress is %lld%%." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」の%@進捗率は%lld%%です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "'%1$@' %2$@ 진행률은 %3$lld%%예요." + } + } + } + }, + "‘%@’ 꼬리표를 삭제할까요? 행동은 삭제되지 않아요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete the tag ‘%@’? Its actions won't be deleted." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タグ「%@」を削除しますか?アクションは削除されません。" + } + } + } + }, + "‘%@’ 목표를 달성했나요?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Did you achieve the goal ‘%@’?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標「%@」を達成しましたか?" + } + } + } + }, + "‘%@’ 목표를 삭제할까요? 다짐도 함께 삭제됩니다." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete the goal ‘%@’? Its quests will also be deleted." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標「%@」を削除しますか?クエストも一緒に削除されます。" + } + } + } + }, + "'%@' 목표에는 아직 다짐이 없어요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The goal '%@' has no quests yet." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標「%@」にはまだクエストがありません。" + } + } + } + }, + "‘%@’ 행동을 삭제할까요? 기록도 함께 삭제됩니다." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete the action ‘%@’? Its records will also be deleted." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アクション「%@」を削除しますか?記録も一緒に削除されます。" + } + } + } + }, + "'%@'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is a time-tracking action. Use start/stop tracking instead." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」は時間計測タイプです。計測の開始/終了を使ってください。" + } + } + } + }, + "'%@'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is a count action. Use add count instead." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」は回数記録タイプです。回数の追加を使ってください。" + } + } + } + }, "%@" : { "localizations" : { "en" : { @@ -17,6 +173,50 @@ } } }, + "%@ · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@" + } + } + } + }, + "%@ · %@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@ %3$@" + } + } + } + }, "%@ %@" : { "localizations" : { "en" : { @@ -149,50 +349,6 @@ } } }, - "%@ · %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ · %2$@" - } - } - } - }, - "%@ · %@ %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · %@ %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · %@ %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ · %2$@ %3$@" - } - } - } - }, "%@ 기록" : { "localizations" : { "en" : { @@ -407,22 +563,6 @@ } } }, - "%lld%%" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - } - } - }, "%lld/%lld" : { "localizations" : { "en" : { @@ -445,6 +585,22 @@ } } }, + "%lld%%" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + } + } + }, "%lld개" : { "localizations" : { "en" : { @@ -579,98 +735,6 @@ } } }, - "'%@ · %@' %@ 진행률: %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@ · %@' %@ progress: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "「%@ · %@」の%@進捗率: %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "'%1$@ · %2$@' %3$@ 진행률: %4$@" - } - } - } - }, - "'%@' %@ 진행률은 %lld%%예요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' %@ progress is %lld%%." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "「%@」の%@進捗率は%lld%%です。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "'%1$@' %2$@ 진행률은 %3$lld%%예요." - } - } - } - }, - "'%@' 목표에는 아직 다짐이 없어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The goal '%@' has no quests yet." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "目標「%@」にはまだクエストがありません。" - } - } - } - }, - "'%@'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' is a time-tracking action. Use start/stop tracking instead." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "「%@」は時間計測タイプです。計測の開始/終了を使ってください。" - } - } - } - }, - "'%@'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' is a count action. Use add count instead." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "「%@」は回数記録タイプです。回数の追加を使ってください。" - } - } - } - }, "+%lld" : { "localizations" : { "en" : { @@ -719,22 +783,6 @@ } } }, - "10초" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "10 sec" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "10秒" - } - } - } - }, "1분" : { "localizations" : { "en" : { @@ -751,22 +799,6 @@ } } }, - "30초" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "30 sec" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "30秒" - } - } - } - }, "3초" : { "localizations" : { "en" : { @@ -799,82 +831,34 @@ } } }, - "iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요." : { + "10초" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sync your data with iPad and other devices via iCloud. Changes take effect after fully quitting and relaunching the app." + "value" : "10 sec" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "iCloudでiPadなどの他のデバイスとデータを同期します。変更はアプリを完全に終了して再起動すると反映されます。" + "value" : "10秒" } } } }, - "‘%@’ 꼬리표를 삭제할까요? 행동은 삭제되지 않아요." : { + "30초" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Delete the tag ‘%@’? Its actions won't be deleted." + "value" : "30 sec" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "タグ「%@」を削除しますか?アクションは削除されません。" - } - } - } - }, - "‘%@’ 목표를 달성했나요?" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Did you achieve the goal ‘%@’?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "目標「%@」を達成しましたか?" - } - } - } - }, - "‘%@’ 목표를 삭제할까요? 다짐도 함께 삭제됩니다." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete the goal ‘%@’? Its quests will also be deleted." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "目標「%@」を削除しますか?クエストも一緒に削除されます。" - } - } - } - }, - "‘%@’ 행동을 삭제할까요? 기록도 함께 삭제됩니다." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete the action ‘%@’? Its records will also be deleted." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アクション「%@」を削除しますか?記録も一緒に削除されます。" + "value" : "30秒" } } } @@ -927,6 +911,38 @@ } } }, + "iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync your data with iPad and other devices via iCloud. Changes take effect after fully quitting and relaunching the app." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloudでiPadなどの他のデバイスとデータを同期します。変更はアプリを完全に終了して再起動すると反映されます。" + } + } + } + }, + "값" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "値" + } + } + } + }, "가장 나중에 시작한 행동" : { "localizations" : { "en" : { @@ -959,23 +975,8 @@ } } }, - "값" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Value" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "値" - } - } - } - }, "개발용" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2471,6 +2472,23 @@ } } }, + "목" : { + "comment" : "요일 축약(목요일)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + } + } + }, "모음" : { "localizations" : { "en" : { @@ -2503,23 +2521,6 @@ } } }, - "목" : { - "comment" : "요일 축약(목요일)", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "木" - } - } - } - }, "목록" : { "localizations" : { "en" : { @@ -2904,22 +2905,6 @@ } } }, - "목표의 하루/주간/월간 진행률(%)을 가져와요. 소속 다짐들의 진행률 평균이에요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gets a goal's daily/weekly/monthly progress (%), averaged across its quests." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "目標の日/週/月の進捗率(%)を取得します。所属クエストの平均です。" - } - } - } - }, "목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)" : { "localizations" : { "en" : { @@ -2936,6 +2921,22 @@ } } }, + "목표의 하루/주간/월간 진행률(%)을 가져와요. 소속 다짐들의 진행률 평균이에요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gets a goal's daily/weekly/monthly progress (%), averaged across its quests." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標の日/週/月の進捗率(%)を取得します。所属クエストの平均です。" + } + } + } + }, "무료 버전에서는 목표당 다짐을 최대 %lld개까지 만들 수 있어요." : { "localizations" : { "en" : { @@ -4143,6 +4144,22 @@ } } }, + "오른쪽 위 ‘순서’를 누르면 다짐 순서를 바꿀 수 있어요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap ‘Reorder’ in the top right to reorder quests." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "右上の「順序」からクエストを並べ替えられます。" + } + } + } + }, "오른쪽 위 + 버튼으로 꼬리표를 추가하세요." : { "localizations" : { "en" : { @@ -4175,22 +4192,6 @@ } } }, - "오른쪽 위 ‘순서’를 누르면 다짐 순서를 바꿀 수 있어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tap ‘Reorder’ in the top right to reorder quests." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "右上の「順序」からクエストを並べ替えられます。" - } - } - } - }, "오른쪽 핸들을 끌어서 순서를 바꾼 뒤 ‘완료’를 누르세요. 순서는 저장되어 유지돼요." : { "localizations" : { "en" : { @@ -4448,6 +4449,23 @@ } } }, + "일" : { + "comment" : "요일 축약(일요일)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sun" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + } + } + }, "이동" : { "localizations" : { "en" : { @@ -4592,23 +4610,6 @@ } } }, - "일" : { - "comment" : "요일 축약(일요일)", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sun" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日" - } - } - } - }, "일·업무" : { "localizations" : { "en" : { @@ -5683,6 +5684,7 @@ } }, "프리미엄 상태 (테스트)" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6588,4 +6590,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/myApp/HaruDanim/IOS/Views/ActionViews.swift b/myApp/HaruDanim/IOS/Views/ActionViews.swift index 3319bd1..1b62b7c 100644 --- a/myApp/HaruDanim/IOS/Views/ActionViews.swift +++ b/myApp/HaruDanim/IOS/Views/ActionViews.swift @@ -280,6 +280,7 @@ struct ActionDetailView: View { .alert("행동 삭제", isPresented: $showingDelete) { Button("삭제", role: .destructive) { context.delete(action) + DataChange.commit(context: context) dismiss() } Button("취소", role: .cancel) {} @@ -471,6 +472,7 @@ struct ActionEditorView: View { newAction.tags = tags context.insert(newAction) } + DataChange.commit(context: context) dismiss() } } diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift index 4485470..a86ccbb 100644 --- a/myApp/HaruDanim/IOS/Views/GoalViews.swift +++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift @@ -141,9 +141,13 @@ struct GoalListView: View { } .onAppear { let math = DayMath() + var statusChanged = false for goal in goals { + let before = goal.status goal.evaluateIfEnded(math: math) + if goal.status != before { statusChanged = true } } + if statusChanged { DataChange.commit(context: context) } #if DEBUG // 검증용: -goalReorder YES → 순서 변경 모드로 시작, -goalScrollBottom YES → 완료된 목표 버튼까지 스크롤 if UserDefaults.standard.bool(forKey: "goalReorder") { @@ -179,6 +183,7 @@ struct GoalListView: View { for (index, goal) in ordered.enumerated() { goal.sortOrder = index } + DataChange.commit(context: context) } /// 다짐 접기/펼치기 토글 헤더 @@ -248,8 +253,14 @@ struct GoalRow: View { } .padding(.vertical, 2) .alert("목표 확인", isPresented: $showingConfirm) { - Button("달성했어요") { goal.status = .achieved } - Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved } + Button("달성했어요") { + goal.status = .achieved + DataChange.commit(context: context) + } + Button("달성하지 못했어요", role: .destructive) { + goal.status = .notAchieved + DataChange.commit(context: context) + } Button("취소", role: .cancel) {} } message: { Text("‘\(goal.title)’ 목표를 달성했나요?") @@ -400,6 +411,7 @@ struct GoalDetailView: View { .swipeActions { Button("삭제", role: .destructive) { context.delete(quest) + DataChange.commit(context: context) } } } @@ -409,6 +421,7 @@ struct GoalDetailView: View { for (index, quest) in ordered.enumerated() { quest.sortOrder = index } + DataChange.commit(context: context) } Button { if !premium.canAddQuest(currentCount: goal.quests.count) { @@ -483,6 +496,8 @@ struct GoalDetailView: View { Button("종료") { if goal.manualFinish() == nil { askManualResult = true + } else { + DataChange.commit(context: context) } } Button("취소", role: .cancel) {} @@ -490,8 +505,14 @@ struct GoalDetailView: View { Text("목표를 지금 종료할까요?") } .alert("목표 확인", isPresented: $askManualResult) { - Button("달성했어요") { goal.status = .achieved } - Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved } + Button("달성했어요") { + goal.status = .achieved + DataChange.commit(context: context) + } + Button("달성하지 못했어요", role: .destructive) { + goal.status = .notAchieved + DataChange.commit(context: context) + } Button("취소", role: .cancel) {} } message: { Text("다짐이 없는 목표예요. 달성했나요?") @@ -499,6 +520,7 @@ struct GoalDetailView: View { .alert("목표 삭제", isPresented: $showingDelete) { Button("삭제", role: .destructive) { context.delete(goal) + DataChange.commit(context: context) dismiss() } Button("취소", role: .cancel) {} @@ -617,6 +639,7 @@ struct GoalEditorView: View { newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1 context.insert(newGoal) } + DataChange.commit(context: context) dismiss() } } diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index abecaa9..4e145e6 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -71,6 +71,8 @@ struct MainView: View { withAnimation { isEditing.toggle() } + // 편집 완료 시 바뀐 배치 순서를 위젯 기본 표시 순서에도 반영 + if !isEditing { DataChange.commit(context: context) } } .fontWeight(isEditing ? .bold : .regular) } @@ -97,7 +99,7 @@ struct MainView: View { ) { action in Button("삭제", role: .destructive) { context.delete(action) - LiveActivityManager.sync(context: context) + DataChange.commit(context: context) deletingAction = nil } Button("취소", role: .cancel) { deletingAction = nil } @@ -326,11 +328,12 @@ struct MainView: View { finish(session) } else { context.insert(TimeSession(action: action, startAt: .now)) - LiveActivityManager.sync(context: context) + DataChange.commit(context: context) } case .count: let entry = CountEntry(action: action, timestamp: .now) context.insert(entry) + DataChange.commit(context: context) if action.promptsForNote { memoEntry = entry } @@ -348,7 +351,7 @@ struct MainView: View { memoSession = session } } - LiveActivityManager.sync(context: context) + DataChange.commit(context: context) } private func stop(_ session: TimeSession) { diff --git a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift index 084c4ed..ae0ac20 100644 --- a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift +++ b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift @@ -314,6 +314,7 @@ struct QuestEditorView: View { target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1 context.insert(target) } + DataChange.commit(context: context) dismiss() } } diff --git a/myApp/HaruDanim/IOS/Views/RecordEditors.swift b/myApp/HaruDanim/IOS/Views/RecordEditors.swift index 4b80c7f..5107f63 100644 --- a/myApp/HaruDanim/IOS/Views/RecordEditors.swift +++ b/myApp/HaruDanim/IOS/Views/RecordEditors.swift @@ -60,7 +60,7 @@ struct ActionRecordsSheet: View { .swipeActions { Button("삭제", role: .destructive) { context.delete(session) - LiveActivityManager.sync(context: context) + DataChange.commit(context: context) } } } @@ -80,6 +80,7 @@ struct ActionRecordsSheet: View { .swipeActions { Button("삭제", role: .destructive) { context.delete(entry) + DataChange.commit(context: context) } } } @@ -208,7 +209,7 @@ struct SessionEditorView: View { Section { Button("기록 삭제", role: .destructive) { context.delete(session) - LiveActivityManager.sync(context: context) + DataChange.commit(context: context) dismiss() } } @@ -224,7 +225,7 @@ struct SessionEditorView: View { session.startAt = startAt session.endAt = isFinished ? max(endAt, startAt) : nil session.note = note.trimmingCharacters(in: .whitespacesAndNewlines) - LiveActivityManager.sync(context: context) + DataChange.commit(context: context) dismiss() } } @@ -276,6 +277,7 @@ struct SessionAddView: View { let session = TimeSession(action: action, startAt: startAt, endAt: max(endAt, startAt)) session.note = note.trimmingCharacters(in: .whitespacesAndNewlines) context.insert(session) + DataChange.commit(context: context) dismiss() } } @@ -313,6 +315,7 @@ struct CountEntryEditorView: View { Section { Button("기록 삭제 (증가 취소)", role: .destructive) { context.delete(entry) + DataChange.commit(context: context) dismiss() } } @@ -328,6 +331,7 @@ struct CountEntryEditorView: View { entry.timestamp = timestamp entry.amount = amount entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines) + DataChange.commit(context: context) dismiss() } } @@ -393,6 +397,7 @@ struct CountAddView: View { ) entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines) context.insert(entry) + DataChange.commit(context: context) dismiss() } } diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 0789e5a..c0c4da7 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -152,6 +152,13 @@ struct SettingsView: View { // '앱 테마와 일치' 옵션의 위젯이 바뀐 테마를 바로 반영하도록 갱신 WidgetCenter.shared.reloadAllTimelines() } + .onChange(of: weekStartWeekday) { + // 주/하루 경계가 바뀌면 위젯의 누적·진행률 집계도 달라진다 + WidgetCenter.shared.reloadAllTimelines() + } + .onChange(of: dayStartMinutes) { + WidgetCenter.shared.reloadAllTimelines() + } .onChange(of: language) { // 시스템의 앱별 언어 오버라이드 방식: 다음 실행부터 적용된다 UserDefaults.standard.set([language], forKey: "AppleLanguages") diff --git a/myApp/HaruDanim/IOS/Views/TagViews.swift b/myApp/HaruDanim/IOS/Views/TagViews.swift index 8cd6773..39f4de3 100644 --- a/myApp/HaruDanim/IOS/Views/TagViews.swift +++ b/myApp/HaruDanim/IOS/Views/TagViews.swift @@ -49,6 +49,7 @@ struct TagListView: View { for (index, tag) in ordered.enumerated() { tag.sortOrder = index } + DataChange.commit(context: context) } if tags.isEmpty { ContentUnavailableView( @@ -91,6 +92,7 @@ struct TagListView: View { ) { tag in Button("삭제", role: .destructive) { context.delete(tag) + DataChange.commit(context: context) deletingTag = nil } Button("취소", role: .cancel) { deletingTag = nil } @@ -182,6 +184,7 @@ struct TagEditorView: View { newTag.sortOrder = (allTags.map(\.sortOrder).max() ?? -1) + 1 context.insert(newTag) } + DataChange.commit(context: context) dismiss() } } diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 49a1007..9e691e6 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -90,6 +90,19 @@ struct QuestProgress { math.dayKeys(in: range).filter(isActiveDay).count } + /// 해당 시점의 논리적 하루가 이 다짐의 수행일인지 (목표 하루 진행률의 모수 판정용). + /// 주/월 단위 다짐은 매일이 수행일이고, 특정 기간 다짐은 기간 안에 있을 때만 수행일. + func isScheduled(on now: Date) -> Bool { + switch quest.period { + case .daily: + return isActiveDay(math.dayKey(for: now)) + case .weekly, .monthly: + return true + case .custom: + return currentPeriodRange(now: now)?.contains(now) ?? false + } + } + // MARK: 현재 주기 진행률 /// 현재 주기의 실제 시각 범위. 하루 단위 다짐이 오늘 적용일이 아니면 nil. @@ -185,10 +198,16 @@ extension Goal { 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) { + // 하루 진행률은 오늘이 수행일인 다짐만 모수로 삼는다 (쉬는 요일 다짐 제외) + let counted = span == .day + ? quests.filter { QuestProgress(quest: $0, math: math).isScheduled(on: now) } + : quests + // 오늘 수행할 다짐이 하나도 없는 날은 0%가 아니라 완료 상태(100%)로 표기 + guard !counted.isEmpty else { return 1 } + let sum = counted.reduce(0.0) { $0 + QuestProgress(quest: $1, math: math).spanProgress(span, now: now).ratio } - return sum / Double(quests.count) + return sum / Double(counted.count) } /// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신.