fix: resolve widget sync crashes via local AppGroup container and improve goal widget to show all sub-goals

위젯 동기화 크래시 수정:
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-11 21:35:20 +09:00
parent 3dd53c4fc6
commit fbd4810628
6 changed files with 121 additions and 5 deletions

View File

@ -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<Void, Never>?
/// 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()
}
}
}

View File

@ -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

View File

@ -29,6 +29,8 @@ struct Haru_DanimApp: App {
WatchSyncManager.shared.pushSnapshot()
}
WatchSyncManager.shared.activate()
// CloudKit ( · ) ··
CloudSyncMonitor.shared.start(container: container)
}
var body: some Scene {

View File

@ -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)

View File

@ -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,

View File

@ -6,6 +6,8 @@
// - , /
// (Button(intent:)) .
// - DataStore.shared(App Group DB) .
// CloudKit (DataStore ),
// CloudKit .
//
import AppIntents