From fbd4810628b297f673668c3cf6d34199417a535d Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 11 Jul 2026 21:35:20 +0900 Subject: [PATCH] fix: resolve widget sync crashes via local AppGroup container and improve goal widget to show all sub-goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위젯 동기화 크래시 수정: - DataStore: CloudKit 미러링을 메인 앱 프로세스 전담으로 분리. 위젯 등 앱 확장(.appex)은 CloudKit 엔타이틀먼트가 없고 메모리·수명 제약으로 미러링 설정이 크래시/행을 유발하므로, 확장 프로세스는 항상 같은 App Group 스토어 파일을 로컬 전용(cloudKitDatabase: .none)으로 연다. 확장이 쓴 변경은 메인 앱이 히스토리로 집어 CloudKit에 내보냄 - CloudSyncMonitor 신설: 메인 앱이 NSPersistentStoreRemoteChange (다른 기기의 CloudKit 가져오기·위젯 인텐트의 쓰기)를 디바운스로 받아 라이브 액티비티 → 위젯 타임라인 → 워치 스냅숏 순으로 즉시 갱신 - 인터랙티브 위젯(AppIntent)의 컨테이너 매핑 점검: IntentStore.commit이 저장 직후 reloadAllTimelines를 이미 보장함을 확인·문서화 모음 탭 목표 진행 현황 카드 개선: - '다짐별로 보기'의 상위 3개 제한 제거 — 모든 다짐 확인 가능 - 화면 균형을 위해 접힘/펼침 UX 적용: iPhone은 3개, iPad는 6개까지 접힌 상태로 보여주고 '다짐 N개 더 보기' 버튼으로 카드 안에서 펼침 (카드 탭 이동과 충돌하지 않는 독립 버튼, 스냅 애니메이션) - DebugSeed: 접힘/펼침 검증용 4번째 다짐 추가, -expandGoalCard 인자 추가 검증: iPhone 17 Pro·iPad Pro 11 시뮬레이터에서 접힘/펼침 렌더링과 사이드바 레이아웃 확인, 전체 타깃(iOS·워치·위젯) 빌드 성공 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../HaruDanim/IOS/Core/CloudSyncMonitor.swift | 49 +++++++++++++++++ myApp/HaruDanim/IOS/Core/DebugSeed.swift | 8 +++ myApp/HaruDanim/IOS/Haru_DanimApp.swift | 2 + myApp/HaruDanim/IOS/Views/MainView.swift | 52 +++++++++++++++++-- myApp/HaruDanim/Shared/DataStore.swift | 13 ++++- myApp/HaruDanim/Shared/HaruDanimIntents.swift | 2 + 6 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Core/CloudSyncMonitor.swift diff --git a/myApp/HaruDanim/IOS/Core/CloudSyncMonitor.swift b/myApp/HaruDanim/IOS/Core/CloudSyncMonitor.swift new file mode 100644 index 0000000..a26af3e --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/CloudSyncMonitor.swift @@ -0,0 +1,49 @@ +// +// CloudSyncMonitor.swift +// Haru_Danim +// +// CloudKit 원격 변경 반영 (메인 앱 전용) +// - SwiftData의 CloudKit 스토어는 원격 가져오기·다른 프로세스(위젯 인텐트)의 쓰기 때 +// NSPersistentStoreRemoteChange 알림을 올린다. 이를 받아 위젯 타임라인·워치 스냅숏· +// 라이브 액티비티를 갱신해, 다른 기기에서 바뀐 데이터가 위젯에 바로 반영되게 한다. +// - 가져오기 중에는 알림이 짧은 간격으로 여러 번 오므로 디바운스로 한 번만 갱신한다. +// + +import CoreData +import SwiftData +import WidgetKit + +@MainActor +final class CloudSyncMonitor { + static let shared = CloudSyncMonitor() + + private var observer: (any NSObjectProtocol)? + private var refreshTask: Task? + + /// 앱 시작 시 1회 호출. 이후 원격 변경이 올 때마다 디바운스 갱신을 예약한다. + func start(container: ModelContainer) { + guard observer == nil else { return } + observer = NotificationCenter.default.addObserver( + forName: .NSPersistentStoreRemoteChange, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + CloudSyncMonitor.shared.scheduleRefresh(container: container) + } + } + } + + private func scheduleRefresh(container: ModelContainer) { + refreshTask?.cancel() + refreshTask = Task { @MainActor in + // CloudKit 가져오기 묶음이 끝나길 기다렸다가 한 번만 갱신 + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + // 다른 기기에서 세션이 시작/종료됐을 수 있으므로 라이브 액티비티부터 맞춘다 + LiveActivityManager.sync(context: container.mainContext) + WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() + } + } +} diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index 8a9edd3..09c498f 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -153,6 +153,14 @@ enum DebugSeed { runQuest.targetSeconds = 30 * 60 runQuest.direction = .atLeast context.insert(runQuest) + // 다짐 4개째: 모음 탭 목표 카드의 '더 보기' 접힘/펼침 검증용 + let pushupQuest = Quest(goal: health) + pushupQuest.targetAction = pushup + pushupQuest.measure = .count + pushupQuest.period = .daily + pushupQuest.targetCount = 20 + pushupQuest.direction = .atLeast + context.insert(pushupQuest) // '이하 유지' 다짐: 영상 시청 하루 1시간 이하 (달성률 로직 검증용) let videoQuest = Quest(goal: health) videoQuest.targetAction = video diff --git a/myApp/HaruDanim/IOS/Haru_DanimApp.swift b/myApp/HaruDanim/IOS/Haru_DanimApp.swift index 056fa47..8fb7ddf 100644 --- a/myApp/HaruDanim/IOS/Haru_DanimApp.swift +++ b/myApp/HaruDanim/IOS/Haru_DanimApp.swift @@ -29,6 +29,8 @@ struct Haru_DanimApp: App { WatchSyncManager.shared.pushSnapshot() } WatchSyncManager.shared.activate() + // CloudKit 원격 변경(다른 기기·위젯 인텐트의 쓰기)이 오면 위젯·워치·라이브 액티비티 갱신 + CloudSyncMonitor.shared.start(container: container) } var body: some Scene { diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index d59f58d..69153ff 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -411,10 +411,23 @@ struct GoalSummaryCard: View { @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue + /// 다짐이 많아도 카드가 화면을 다 덮지 않게, 접힌 상태에서는 요약 개수만 보여주고 펼쳐서 전체를 본다 + @State private var showsAllQuests: Bool = { + #if DEBUG + // 검증용: -expandGoalCard YES → 펼친 상태로 시작 + return UserDefaults.standard.bool(forKey: "expandGoalCard") + #else + return false + #endif + }() + private var cardStyle: GoalCardStyle { GoalCardStyle(rawValue: cardStyleRaw) ?? .perQuest } + /// 접힌 상태에서 보여줄 다짐 수 (iPad는 카드가 그리드에 나란히 놓여 세로 여유가 있음) + private var collapsedQuestLimit: Int { DeviceLayout.isPad ? 6 : 3 } + var body: some View { NavigationLink { GoalDetailView(goal: goal) @@ -453,9 +466,7 @@ struct GoalSummaryCard: View { // 설정의 표시 방식이 선택된 모든 목표 카드에 동일하게 적용됨 switch cardStyle { case .perQuest: - ForEach(goal.sortedQuests.prefix(3)) { quest in - questSpanBlock(quest) - } + perQuestList case .combined: combinedBlock } @@ -469,6 +480,41 @@ struct GoalSummaryCard: View { .buttonStyle(.plain) } + /// 표시 방식 A 목록: 접힌 상태에서는 요약 개수까지만, 펼치면 소속 다짐 전체를 표시 + @ViewBuilder + private var perQuestList: some View { + let quests = goal.sortedQuests + let visible = showsAllQuests ? quests : Array(quests.prefix(collapsedQuestLimit)) + ForEach(visible) { quest in + questSpanBlock(quest) + } + if quests.count > collapsedQuestLimit { + questExpandToggle(hiddenCount: quests.count - collapsedQuestLimit) + } + } + + /// 카드가 NavigationLink 안에 있어도 이 버튼 영역의 탭은 펼침/접힘으로만 동작한다 + private func questExpandToggle(hiddenCount: Int) -> some View { + Button { + withAnimation(.snappy(duration: 0.25)) { + showsAllQuests.toggle() + } + } label: { + HStack(spacing: 4) { + Text(showsAllQuests ? String(localized: "접기") : String(localized: "다짐 \(hiddenCount)개 더 보기")) + Image(systemName: showsAllQuests ? "chevron.up" : "chevron.down") + .font(.caption2.weight(.semibold)) + } + .font(.caption.weight(.medium)) + .foregroundStyle(AppTheme.green) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .background(AppTheme.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + .buttonStyle(.plain) + } + /// 표시 방식 A: 다짐 하나의 하루/주간/월간 진행률을 각각 표시 private func questSpanBlock(_ quest: Quest) -> some View { let progress = QuestProgress(quest: quest) diff --git a/myApp/HaruDanim/Shared/DataStore.swift b/myApp/HaruDanim/Shared/DataStore.swift index ca39811..f88f985 100644 --- a/myApp/HaruDanim/Shared/DataStore.swift +++ b/myApp/HaruDanim/Shared/DataStore.swift @@ -6,6 +6,9 @@ // - 저장 위치: App Group 컨테이너 (위젯·App Intents가 같은 DB를 읽고 쓰기 위함) // - 기존 앱 샌드박스의 default.store는 최초 1회 App Group으로 이관 // - iCloud 동기화(프리미엄): 켜져 있으면 CloudKit 연동 구성으로 컨테이너 생성, 실패 시 로컬로 폴백 +// - CloudKit 미러링은 메인 앱 프로세스 전담. 위젯 확장(.appex)은 항상 로컬 전용 구성으로 +// 같은 스토어 파일만 연다 — 확장에는 CloudKit 엔타이틀먼트가 없고 메모리·수명 제약으로 +// 미러링 설정이 크래시/행을 일으킨다. 확장이 쓴 변경은 메인 앱이 히스토리로 집어 CloudKit에 내보낸다. // import Foundation @@ -40,6 +43,11 @@ nonisolated enum DataStore { return base.appending(path: "HaruDanim.store") } + /// 위젯 등 앱 확장(.appex) 프로세스에서 실행 중인지 + static var isExtensionProcess: Bool { + Bundle.main.bundleURL.pathExtension == "appex" + } + /// iCloud 동기화를 시도해야 하는 상태인지 (프리미엄 + 설정 on) static var wantsCloudSync: Bool { #if DEBUG @@ -50,10 +58,11 @@ nonisolated enum DataStore { return PremiumGate.isUnlocked(.cloudSync) && AppGroup.defaults.bool(forKey: cloudSyncKey) } - /// 앱·위젯·인텐트가 공유하는 컨테이너 생성 + /// 앱·위젯·인텐트가 공유하는 컨테이너 생성. + /// CloudKit 연동은 메인 앱 프로세스에서만 — 확장은 같은 파일을 로컬 전용으로 연다. static func makeContainer() -> ModelContainer { migrateLegacyStoreIfNeeded() - if wantsCloudSync { + if wantsCloudSync && !isExtensionProcess { do { let config = ModelConfiguration( schema: schema, diff --git a/myApp/HaruDanim/Shared/HaruDanimIntents.swift b/myApp/HaruDanim/Shared/HaruDanimIntents.swift index 5c077d0..7b0d1c1 100644 --- a/myApp/HaruDanim/Shared/HaruDanimIntents.swift +++ b/myApp/HaruDanim/Shared/HaruDanimIntents.swift @@ -6,6 +6,8 @@ // - 앱 타깃과 위젯 확장 타깃 양쪽에 포함되어, 단축어 앱/시리와 // 인터랙티브 위젯(Button(intent:))이 같은 인텐트를 사용한다. // - 데이터는 DataStore.shared(App Group DB)를 사용하므로 어느 프로세스에서 실행돼도 안전. +// 위젯 확장 프로세스에서는 같은 스토어 파일을 CloudKit 없이 로컬 전용으로 열고(DataStore 참고), +// 확장이 쓴 변경은 메인 앱이 원격 변경 알림으로 받아 CloudKit에 내보낸다. // import AppIntents