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:
parent
3dd53c4fc6
commit
fbd4810628
49
myApp/HaruDanim/IOS/Core/CloudSyncMonitor.swift
Normal file
49
myApp/HaruDanim/IOS/Core/CloudSyncMonitor.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -29,6 +29,8 @@ struct Haru_DanimApp: App {
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
WatchSyncManager.shared.activate()
|
||||
// CloudKit 원격 변경(다른 기기·위젯 인텐트의 쓰기)이 오면 위젯·워치·라이브 액티비티 갱신
|
||||
CloudSyncMonitor.shared.start(container: container)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
// - 앱 타깃과 위젯 확장 타깃 양쪽에 포함되어, 단축어 앱/시리와
|
||||
// 인터랙티브 위젯(Button(intent:))이 같은 인텐트를 사용한다.
|
||||
// - 데이터는 DataStore.shared(App Group DB)를 사용하므로 어느 프로세스에서 실행돼도 안전.
|
||||
// 위젯 확장 프로세스에서는 같은 스토어 파일을 CloudKit 없이 로컬 전용으로 열고(DataStore 참고),
|
||||
// 확장이 쓴 변경은 메인 앱이 원격 변경 알림으로 받아 CloudKit에 내보낸다.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user