fix: correct daily goal progress calculation and optimize widget refresh logic
- 목표 하루 진행률은 오늘이 수행일인 다짐만 모수로 계산 (요일·날짜 지정으로 오늘 쉬는 다짐은 분모에서 제외, QuestProgress.isScheduled 신설) - 오늘 수행할 다짐이 하나도 없는 날은 0% 대신 100%(완료 상태)로 표기 - 주간/월간 진행률은 기존대로 전체 다짐 기준 유지 - 이벤트 기반 위젯 새로 고침: 앱 내 모든 핵심 Mutation(측정 시작/종료, 횟수 추가, 기록 추가·수정·삭제, 행동·꼬리표·목표·다짐 CRUD, 순서 변경, 목표 상태 변경)의 끝단에서 DataChange.commit() 호출 — 저장 즉시 확정 후 Live Activity·위젯 타임라인·워치 스냅숏을 한 번에 동기화 - 주기적 백그라운드 새로 고침 없이 변경 시점에만 갱신해 WidgetKit 업데이트 예산 절약 (타이머 표시는 기존 Text(style: .timer)로 시스템이 자체 렌더링하므로 타임라인 소모 없음) - 하루 시작 시간·주 시작 요일 설정 변경 시에도 위젯 집계 갱신 - 시뮬레이터 검증: 월·수·금 다짐이 있는 목표의 토요일 하루 진행률이 33%가 아닌 50%(오늘 수행 다짐 2개 평균)로 표시됨 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
081c388f00
commit
2567b53b71
23
myApp/HaruDanim/IOS/Core/DataChange.swift
Normal file
23
myApp/HaruDanim/IOS/Core/DataChange.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -314,6 +314,7 @@ struct QuestEditorView: View {
|
||||
target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1
|
||||
context.insert(target)
|
||||
}
|
||||
DataChange.commit(context: context)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user