- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변) - RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일) - DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회) - Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백 - -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
340 lines
14 KiB
Swift
340 lines
14 KiB
Swift
//
|
|
// DiaryDefaultsSheet.swift
|
|
// Haru_Danim
|
|
//
|
|
// 일기 날짜 화면의 "기본 표시 설정" (1.2 — 달력 툴바에서 진입, CLAUDE.md §6.7).
|
|
// 매일 같은 목표·행동을 손으로 끄는 반복을 없앤다: 여기서 정한 기본값이 모든 날짜에
|
|
// 적용되고, 날짜별 필터에서 다르게 고르면 그 날짜만 재정의된다.
|
|
//
|
|
// 저장 구조 (스키마 변경 없음):
|
|
// - 기본값: 기기 로컬 UserDefaults.standard (diary.sectionOrder와 같은 계층 — 기기별 보기 설정)
|
|
// - 날짜별 재정의: 기존 동기화 배열(hiddenGoalIDs·hiddenActionIDs·calendarEventIDs)에
|
|
// UUID 형식이 아닌 마커 문자열(#override)을 함께 저장 — "이 날짜는 기본값 대신 이 목록".
|
|
// 구버전(1.0/1.1)은 마커가 어떤 uuid·이벤트 id와도 매칭되지 않아 그대로 무해하게 읽고,
|
|
// 마커 없는 비어 있지 않은 목록(1.1 이전에 날짜별로 고른 것)은 재정의로 계속 존중한다.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import EventKit
|
|
|
|
// MARK: - 기본값 저장·효과 계산
|
|
|
|
@MainActor
|
|
enum DiaryDefaults {
|
|
static let hiddenGoalsKey = "diary.defaultHiddenGoals"
|
|
static let hiddenActionsKey = "diary.defaultHiddenActions"
|
|
static let calendarModeKey = "diary.defaultCalendarMode"
|
|
static let calendarIDsKey = "diary.defaultCalendarIDs"
|
|
|
|
/// 날짜별 재정의 마커. UUID·이벤트 id 형식이 아니므로 어떤 대상과도 매칭되지 않는다 —
|
|
/// 구버전이 이 배열을 읽어도 표시 결과가 동일하다 (스키마 무변경의 핵심).
|
|
static let overrideMarker = "#override"
|
|
|
|
/// 캘린더 일정 기본 표시 방식
|
|
enum CalendarMode: String, CaseIterable {
|
|
case none, all, selected
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .none: return String(localized: "표시 안 함")
|
|
case .all: return String(localized: "모든 캘린더")
|
|
case .selected: return String(localized: "선택한 캘린더만")
|
|
}
|
|
}
|
|
}
|
|
|
|
static var defaults: UserDefaults { .standard }
|
|
|
|
static var calendarMode: CalendarMode {
|
|
CalendarMode(rawValue: defaults.string(forKey: calendarModeKey) ?? "") ?? .none
|
|
}
|
|
|
|
static var hiddenGoalIDs: Set<String> {
|
|
Set((defaults.string(forKey: hiddenGoalsKey) ?? "").split(separator: ",").map(String.init))
|
|
}
|
|
|
|
static var hiddenActionIDs: Set<String> {
|
|
Set((defaults.string(forKey: hiddenActionsKey) ?? "").split(separator: ",").map(String.init))
|
|
}
|
|
|
|
static var calendarIDs: Set<String> {
|
|
Set((defaults.string(forKey: calendarIDsKey) ?? "").split(separator: ",").map(String.init))
|
|
}
|
|
|
|
// MARK: 재정의 목록 도우미
|
|
|
|
static func isOverridden(_ list: [String]) -> Bool {
|
|
list.contains(overrideMarker)
|
|
}
|
|
|
|
/// 저장된 목록에서 마커를 뺀 실제 id들
|
|
static func storedIDs(_ list: [String]) -> [String] {
|
|
list.filter { $0 != overrideMarker }
|
|
}
|
|
|
|
/// 날짜별 재정의로 저장할 목록 (마커 + id들)
|
|
static func overrideList(_ ids: [String]) -> [String] {
|
|
[overrideMarker] + ids
|
|
}
|
|
|
|
/// 숨김 목록의 날짜별 효과: 재정의(마커) → 그 목록, 1.1 이전 날짜별 목록 → 그대로,
|
|
/// 아무것도 없으면 → 기기 기본값
|
|
private static func effectiveHidden(list: [String], fallback: Set<String>) -> Set<String> {
|
|
if isOverridden(list) { return Set(storedIDs(list)) }
|
|
if !list.isEmpty { return Set(list) }
|
|
return fallback
|
|
}
|
|
|
|
static func effectiveHiddenGoals(for entry: DiaryEntry) -> Set<String> {
|
|
effectiveHidden(list: entry.hiddenGoalIDs, fallback: hiddenGoalIDs)
|
|
}
|
|
|
|
static func effectiveHiddenActions(for entry: DiaryEntry) -> Set<String> {
|
|
effectiveHidden(list: entry.hiddenActionIDs, fallback: hiddenActionIDs)
|
|
}
|
|
|
|
/// 그날 캘린더 일정의 효과 선택: 재정의/이전 날짜별 선택 → 그 목록,
|
|
/// 없으면 기본 모드(없음/전체/선택한 캘린더)를 그날의 실제 일정에 적용
|
|
static func effectiveEvents(for entry: DiaryEntry, events: [EKEvent]) -> [EKEvent] {
|
|
if isOverridden(entry.calendarEventIDs) || !entry.calendarEventIDs.isEmpty {
|
|
let ids = Set(storedIDs(entry.calendarEventIDs))
|
|
guard !ids.isEmpty else { return [] }
|
|
return events.filter { ids.contains($0.eventIdentifier ?? "") }
|
|
}
|
|
switch calendarMode {
|
|
case .none:
|
|
return []
|
|
case .all:
|
|
return events
|
|
case .selected:
|
|
let ids = calendarIDs
|
|
return events.filter { ids.contains($0.calendar?.calendarIdentifier ?? "") }
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
/// 검증용 (-diaryDefaultsSeed YES): 첫 목표·첫 행동을 기본 숨김 + 캘린더 기본 '모든 캘린더'
|
|
static func seedForVerificationIfRequested(context: ModelContext) {
|
|
guard UserDefaults.standard.bool(forKey: "diaryDefaultsSeed") else { return }
|
|
let goals = (try? context.fetch(FetchDescriptor<Goal>(
|
|
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
|
|
))) ?? []
|
|
if let first = goals.first {
|
|
defaults.set(first.uuid.uuidString, forKey: hiddenGoalsKey)
|
|
}
|
|
let actions = LocalPrefs.orderedActions(
|
|
(try? context.fetch(FetchDescriptor<Action>())) ?? []
|
|
)
|
|
if let first = actions.first {
|
|
defaults.set(first.uuid.uuidString, forKey: hiddenActionsKey)
|
|
}
|
|
defaults.set(CalendarMode.all.rawValue, forKey: calendarModeKey)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - 기본 표시 설정 시트
|
|
|
|
struct DiaryDefaultsSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
|
|
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
|
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
|
|
|
@AppStorage(DiaryDefaults.hiddenGoalsKey) private var hiddenGoalsRaw = ""
|
|
@AppStorage(DiaryDefaults.hiddenActionsKey) private var hiddenActionsRaw = ""
|
|
@AppStorage(DiaryDefaults.calendarModeKey) private var calendarModeRaw = ""
|
|
@AppStorage(DiaryDefaults.calendarIDsKey) private var calendarIDsRaw = ""
|
|
|
|
@State private var calendars: [EKCalendar] = []
|
|
@State private var calendarAccessDenied = false
|
|
|
|
private var orderedActions: [Action] {
|
|
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
|
|
}
|
|
|
|
private var activeGoals: [Goal] { goals.filter { $0.status == .inProgress } }
|
|
private var finishedGoals: [Goal] { goals.filter { $0.status != .inProgress } }
|
|
|
|
private var calendarMode: DiaryDefaults.CalendarMode {
|
|
DiaryDefaults.CalendarMode(rawValue: calendarModeRaw) ?? .none
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
calendarSection
|
|
goalSection
|
|
actionSection
|
|
}
|
|
.tint(AppTheme.green)
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("기본 표시 설정")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("완료") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
.task(id: calendarModeRaw) {
|
|
await loadCalendarsIfNeeded()
|
|
}
|
|
}
|
|
|
|
// MARK: 캘린더 기본값
|
|
|
|
@ViewBuilder
|
|
private var calendarSection: some View {
|
|
Section {
|
|
Picker("캘린더 일정 기본 표시", selection: $calendarModeRaw) {
|
|
ForEach(DiaryDefaults.CalendarMode.allCases, id: \.rawValue) { mode in
|
|
Text(mode.label).tag(mode.rawValue)
|
|
}
|
|
}
|
|
if calendarMode != .none && calendarAccessDenied {
|
|
Label("캘린더 접근이 꺼져 있어요", systemImage: "calendar.badge.exclamationmark")
|
|
.font(.callout)
|
|
Button("설정에서 허용하기") {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
if calendarMode == .selected {
|
|
ForEach(calendars, id: \.calendarIdentifier) { calendar in
|
|
calendarRow(calendar)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("캘린더 일정")
|
|
} footer: {
|
|
Text("모든 날짜의 타임테이블에 캘린더 일정을 어떻게 넣을지 정해요. 날짜별 캘린더 버튼에서 그 날짜만 다르게 고를 수 있어요.")
|
|
}
|
|
}
|
|
|
|
private func calendarRow(_ calendar: EKCalendar) -> some View {
|
|
let id = calendar.calendarIdentifier
|
|
let isOn = DiaryDefaults.calendarIDs.contains(id)
|
|
return Button {
|
|
var ids = DiaryDefaults.calendarIDs
|
|
if isOn { ids.remove(id) } else { ids.insert(id) }
|
|
calendarIDsRaw = ids.sorted().joined(separator: ",")
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(Color(cgColor: calendar.cgColor))
|
|
.frame(width: 10, height: 10)
|
|
Text(calendar.title)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(isOn ? AppTheme.green : .secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func loadCalendarsIfNeeded() async {
|
|
guard calendarMode != .none else { return }
|
|
let granted = await DiaryCalendarEvents.requestAccess()
|
|
calendarAccessDenied = !granted
|
|
guard granted else { return }
|
|
calendars = DiaryCalendarEvents.store.calendars(for: .event)
|
|
.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
|
|
}
|
|
|
|
// MARK: 목표 기본값
|
|
|
|
@ViewBuilder
|
|
private var goalSection: some View {
|
|
Section {
|
|
if goals.isEmpty {
|
|
Text("아직 목표가 없어요")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
ForEach(activeGoals, id: \.uuid) { goal in
|
|
goalRow(goal)
|
|
}
|
|
ForEach(finishedGoals, id: \.uuid) { goal in
|
|
goalRow(goal)
|
|
}
|
|
} header: {
|
|
Text("'이 날의 목표' 카드")
|
|
} footer: {
|
|
Text("끄면 그 목표는 모든 날짜의 카드에서 기본으로 숨겨져요. 날짜별 목표 필터에서 그 날짜만 다르게 고를 수 있어요.")
|
|
}
|
|
}
|
|
|
|
private func goalRow(_ goal: Goal) -> some View {
|
|
Toggle(isOn: Binding(
|
|
get: { !DiaryDefaults.hiddenGoalIDs.contains(goal.uuid.uuidString) },
|
|
set: { visible in
|
|
var ids = DiaryDefaults.hiddenGoalIDs
|
|
if visible { ids.remove(goal.uuid.uuidString) } else { ids.insert(goal.uuid.uuidString) }
|
|
hiddenGoalsRaw = ids.sorted().joined(separator: ",")
|
|
}
|
|
)) {
|
|
HStack(spacing: 10) {
|
|
Image(safeSymbol: goal.symbolName)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 26, height: 26)
|
|
.background(goal.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(goal.title)
|
|
.lineLimit(1)
|
|
if goal.status != .inProgress {
|
|
Text(goal.status.label)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.tint(AppTheme.green)
|
|
}
|
|
|
|
// MARK: 행동 기본값
|
|
|
|
@ViewBuilder
|
|
private var actionSection: some View {
|
|
Section {
|
|
if orderedActions.isEmpty {
|
|
Text("아직 행동이 없어요")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
ForEach(orderedActions, id: \.uuid) { action in
|
|
Toggle(isOn: Binding(
|
|
get: { !DiaryDefaults.hiddenActionIDs.contains(action.uuid.uuidString) },
|
|
set: { visible in
|
|
var ids = DiaryDefaults.hiddenActionIDs
|
|
if visible { ids.remove(action.uuid.uuidString) } else { ids.insert(action.uuid.uuidString) }
|
|
hiddenActionsRaw = ids.sorted().joined(separator: ",")
|
|
}
|
|
)) {
|
|
HStack(spacing: 10) {
|
|
Image(safeSymbol: action.symbolName)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 26, height: 26)
|
|
.background(action.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
|
Text(action.name)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.tint(AppTheme.green)
|
|
}
|
|
} header: {
|
|
Text("타임테이블 행동")
|
|
} footer: {
|
|
Text("끄면 그 행동은 모든 날짜의 타임테이블에서 기본으로 숨겨져요 (요약 수치·기록 목록은 그대로). 날짜별 필터로 그 날짜만 다르게 고를 수 있어요.\n이 화면의 기본값은 이 기기에만 저장돼요.")
|
|
}
|
|
}
|
|
}
|