mycode/myApp/HaruDanim/IOS/Views/DiaryDefaultsSheet.swift
songyc macbook 5b526fa650 feat(compat): iOS 하한 18.0 인하 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더·Release 인라이너 크래시 우회
- 배포 타깃 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
2026-08-11 16:31:40 +09:00

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이 화면의 기본값은 이 기기에만 저장돼요.")
}
}
}