- 검사가 종료 실행 기기 기준이라 기기별 값이면 교차 사용(아이패드 시작→아이폰 종료)에서 결과가 갈리던 문제 해소 — 값 자체를 모든 기기에서 통일 - SettingsCloudSync: 시작 시 iCloud 값 채택(없으면 로컬 값 시드), 외부 변경·포그라운드 복귀 때 pull, 설정 변경 때 push(동일 값 가드). App Group 저장은 유지(위젯·인텐트 읽기 경로 무변경), CloudKit 스키마·프리미엄 토글과 무관 — ubiquity-kvstore 엔타이틀먼트만 추가 - 설정: 측정 섹션을 나눠 '짧은 기록 무시' 바로 밑에 전 기기 공통 안내 푸터, '장시간 측정 알림'은 기기별 유지 명시. 도움말도 갱신, en/ja 번역(missing 0·stale 0, 실렌더 확인) - whats-new-1.1 3개 언어에 항목 추가, CLAUDE.md §6.8 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
99 lines
5.0 KiB
Swift
99 lines
5.0 KiB
Swift
//
|
|
// Haru_DanimApp.swift
|
|
// Haru_Danim
|
|
//
|
|
// Created by 송예찬 on 7/7/26.
|
|
//
|
|
|
|
import AppIntents
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
@main
|
|
struct Haru_DanimApp: App {
|
|
/// App Group 컨테이너의 공유 DB (위젯·App Intents와 공용, 프리미엄이면 CloudKit 연동)
|
|
private let container: ModelContainer
|
|
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
init() {
|
|
#if DEBUG
|
|
// 마케팅 촬영용 시간대 확인 프로브 (-marketingTZ <IANA id> → Documents/tz-probe.txt).
|
|
// 시간대 자체는 **시뮬레이터를 SIMCTL_CHILD_TZ 환경으로 부팅**해야 전 프로세스에 적용된다
|
|
// (Marketing/README 참고). launch 시점 env는 간헐 유실되고, 프로세스 안에서의
|
|
// setenv+resetSystemTimeZone·NSTimeZone.default 대입은 FormatStyle에 반영되지 않음(실측).
|
|
// 이 훅은 촬영 스크립트가 로케일마다 실제 적용 시간대를 검증하는 용도다.
|
|
if let tz = UserDefaults.standard.string(forKey: "marketingTZ") {
|
|
if let zone = TimeZone(identifier: tz) {
|
|
NSTimeZone.default = zone // 무해한 보조 시도 — 판정은 프로브 파일로
|
|
}
|
|
let probe = "current=\(TimeZone.current.identifier) default=\(NSTimeZone.default.identifier) now=\(Date.now.formatted(date: .abbreviated, time: .standard))"
|
|
try? probe.write(to: URL.documentsDirectory.appending(path: "tz-probe.txt"),
|
|
atomically: true, encoding: .utf8)
|
|
}
|
|
#endif
|
|
// 실결제 권한 제공자 주입 (다른 무엇보다 먼저 — shared 최초 접근 전에 교체)
|
|
PremiumManager.bootstrap(provider: StoreKitEntitlementProvider())
|
|
SettingsMigration.runIfNeeded()
|
|
// 짧은 기록 무시 등 "모든 기기 공통" 설정을 iCloud 키-값 저장소와 동기화
|
|
SettingsCloudSync.shared.start()
|
|
container = DataStore.shared
|
|
DataStore.ensureUniqueEntityIDs(context: container.mainContext)
|
|
// 배치·노출 등 기기별 UI 설정: 구버전에 모델(@Model)로 저장하던 값을 로컬로 1회 이관
|
|
LocalPrefs.adoptLegacyModelValuesIfNeeded(context: container.mainContext)
|
|
// 시리 단축어/위젯 인텐트가 앱 프로세스에서 실행될 때 Live Activity·워치도 갱신되도록 주입
|
|
IntentHooks.afterMutation = { context in
|
|
LiveActivityManager.sync(context: context)
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
}
|
|
WatchSyncManager.shared.activate()
|
|
// CloudKit 원격 변경(다른 기기·위젯 인텐트의 쓰기)이 오면 위젯·워치·라이브 액티비티 갱신
|
|
CloudSyncMonitor.shared.start(container: container)
|
|
// 시리 문구의 행동 이름 어휘(\(\.$action) 문구) 갱신 — 행동 추가/삭제는 다음 실행에 반영
|
|
HaruDanimShortcuts.updateAppShortcutParameters()
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView()
|
|
}
|
|
.modelContainer(container)
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
// 앱 사용 중 변경된 데이터를 워치에 반영 (활성/백그라운드 전환 시점)
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
if newPhase == .background {
|
|
// 일기 잠금(설정 → 일기): 앱을 벗어나면 다시 잠근다
|
|
DiaryLock.shared.relock()
|
|
}
|
|
if newPhase == .active {
|
|
// 종료일이 지난 목표 판정 — 목표 탭을 열지 않아도
|
|
// 위젯·워치·모음 탭이 오래 '진행 중'으로 남지 않게 한다
|
|
Self.evaluateEndedGoals(context: container.mainContext)
|
|
// 백그라운드(워치 명령)에서 시작이 거부됐던 Live Activity 재시도 +
|
|
// 앱이 꺼진 사이 워치에서 바뀐 세션 상태를 다이나믹 아일랜드에 반영
|
|
LiveActivityManager.sync(context: container.mainContext)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 종료일이 지났고 다짐이 있는 진행 중 목표의 달성 여부를 판정해 상태를 갱신.
|
|
/// 목표 탭 진입 시(GoalListView.onAppear) 로직과 동일 — 실행/포그라운드 복귀로 시점만 추가.
|
|
/// 다짐 없는 목표는 evaluateIfEnded가 건드리지 않으므로 '확인 필요' 흐름은 그대로다.
|
|
@MainActor
|
|
private static func evaluateEndedGoals(context: ModelContext) {
|
|
let inProgress = GoalStatus.inProgress.rawValue
|
|
let goals = (try? context.fetch(FetchDescriptor<Goal>(
|
|
predicate: #Predicate { $0.statusRaw == inProgress }
|
|
))) ?? []
|
|
guard !goals.isEmpty else { return }
|
|
let math = DayMath()
|
|
var changed = false
|
|
for goal in goals {
|
|
let before = goal.status
|
|
goal.evaluateIfEnded(math: math)
|
|
if goal.status != before { changed = true }
|
|
}
|
|
if changed { DataChange.commit(context: context) }
|
|
}
|
|
}
|