mycode/myApp/HaruDanim/IOS/Views/HistoryView.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

718 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// HistoryView.swift
// Haru_Danim
//
// : / (·) (CLAUDE.md §6.5 )
//
import SwiftUI
import SwiftData
enum HistoryMode: String, CaseIterable, Identifiable {
case list, timetable
var id: String { rawValue }
var label: String {
switch self {
case .list: return String(localized: "목록")
case .timetable: return String(localized: "타임테이블")
}
}
}
// MARK: -
struct SessionSegmentItem: Identifiable {
let session: TimeSession
let action: Action
let range: Range<Date>
var id: String {
"\(String(describing: session.persistentModelID))-\(range.lowerBound.timeIntervalSinceReferenceDate)"
}
var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) }
}
struct CountItem: Identifiable {
let entry: CountEntry
let action: Action
var id: String { String(describing: entry.persistentModelID) }
}
// MARK: -
struct HistoryView: View {
@Environment(AppRouter.self) private var router
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
// (LocalPrefs )
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
private var allActions: [Action] {
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
}
@State private var selectedDayKey: Date = {
let math = DayMath()
#if DEBUG
// · : -historyDaysAgo N N ( )
let daysAgo = UserDefaults.standard.integer(forKey: "historyDaysAgo")
if daysAgo > 0 {
return math.calendar.date(byAdding: .day, value: -daysAgo, to: math.dayKey(for: .now))!
}
#endif
return math.dayKey(for: .now)
}()
@State private var mode: HistoryMode = {
#if DEBUG
if let raw = UserDefaults.standard.string(forKey: "historyMode"),
let mode = HistoryMode(rawValue: raw) {
return mode
}
#endif
return .list
}()
@State private var timetableWeekly: Bool = {
#if DEBUG
// : -historyWeekly YES
if UserDefaults.standard.bool(forKey: "historyWeekly") { return true }
#endif
return false
}()
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@State private var showingCalendar = false
@State private var showingFilter = false
@State private var showingExport = false
/// " " ( )
@State private var excludedActionIDs: Set<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
var body: some View {
VStack(spacing: 0) {
dateHeader
Picker("보기", selection: $mode) {
ForEach(HistoryMode.allCases) { m in
Text(m.label).tag(m)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
if isFiltering {
filterChip
}
// DB ( ).
// dayKey (SwiftData ).
HistoryRecordsView(
dayKey: selectedDayKey,
mode: mode,
timetableWeekly: $timetableWeekly,
excludedActionIDs: excludedActionIDs,
orderedActions: allActions,
showingExport: $showingExport,
onTapSession: { editingSession = $0 },
onTapEntry: { editingEntry = $0 }
)
}
.background(AppTheme.background)
.navigationTitle("기록")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingExport = true
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel(Text("내보내기"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingFilter = true
} label: {
Image(systemName: isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("기록 필터"))
}
}
.sheet(item: $editingSession) { session in
SessionEditorView(session: session)
}
.sheet(item: $editingEntry) { entry in
CountEntryEditorView(entry: entry)
}
.sheet(isPresented: $showingFilter) {
RecordFilterSheet(
title: "기록 필터",
tags: tags,
actions: allActions,
excludedActionIDs: $excludedActionIDs
)
}
.onAppear {
consumePendingFilter()
#if DEBUG
// : -excludeActions "," , -historyShowFilter YES
if let raw = UserDefaults.standard.string(forKey: "excludeActions") {
let names = Set(raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) })
excludedActionIDs = Set(
allActions.filter { names.contains($0.name) }.map(\.persistentModelID)
)
}
if UserDefaults.standard.bool(forKey: "historyShowFilter") {
showingFilter = true
}
// : -showExport YES
if UserDefaults.standard.bool(forKey: "showExport") {
showingExport = true
}
// : -recordShowEditor session|count
switch UserDefaults.standard.string(forKey: "recordShowEditor") {
case "session":
editingSession = allActions.flatMap(\.sessions).sorted { $0.startAt > $1.startAt }.first
case "count":
editingEntry = allActions.flatMap(\.countEntries).sorted { $0.timestamp > $1.timestamp }.first
default:
break
}
#endif
}
.onChange(of: router.pendingHistoryActionID) {
consumePendingFilter()
}
}
/// ' ' ( )
private func consumePendingFilter() {
guard let id = router.pendingHistoryActionID else { return }
//
excludedActionIDs = Set(allActions.map(\.persistentModelID)).subtracting([id])
mode = .list
selectedDayKey = math.dayKey(for: .now)
router.pendingHistoryActionID = nil
}
// MARK: ( , )
private var isFiltering: Bool {
// ID
// ( · )
allActions.contains { !passesFilter($0) }
}
private func passesFilter(_ action: Action) -> Bool {
!excludedActionIDs.contains(action.persistentModelID)
}
private var filterLabel: String {
let names = allActions.filter(passesFilter).map(\.name)
if names.isEmpty { return String(localized: "표시할 행동 없음") }
return names.count <= 2
? names.joined(separator: ", ")
: String(localized: "행동 \(names.count)/\(allActions.count)개 표시 중")
}
private var filterChip: some View {
HStack(spacing: 6) {
Image(systemName: "line.3.horizontal.decrease")
.font(.caption2.weight(.semibold))
Text(filterLabel)
.font(.caption.weight(.semibold))
.lineLimit(1)
Button {
withAnimation {
excludedActionIDs = []
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("필터 해제"))
}
.foregroundStyle(AppTheme.green)
.padding(.leading, 10)
.padding(.trailing, 2)
.padding(.vertical, 4)
.background(AppTheme.green.opacity(0.12), in: Capsule())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 6)
}
// MARK: ( )
private var dateHeader: some View {
HStack {
Button {
moveDay(-1)
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
.accessibilityLabel(Text("이전 날짜"))
Spacer()
Button {
showingCalendar = true
} label: {
VStack(spacing: 1) {
HStack(spacing: 4) {
Text(Format.fullDate(selectedDayKey))
.font(.headline)
.foregroundStyle(.primary)
Image(systemName: "chevron.down.circle.fill")
.font(.caption)
.foregroundStyle(AppTheme.green)
}
if math.dayKey(for: .now) == selectedDayKey {
Text("오늘")
.font(.caption2)
.foregroundStyle(AppTheme.green)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showingCalendar) {
DatePicker(
"날짜 선택",
selection: calendarSelection,
displayedComponents: .date
)
.datePickerStyle(.graphical)
.frame(minWidth: 320, minHeight: 340)
.padding(8)
.presentationCompactAdaptation(.popover)
}
Spacer()
Button {
moveDay(1)
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
.accessibilityLabel(Text("다음 날짜"))
Button("오늘") {
selectedDayKey = math.dayKey(for: .now)
}
.font(.caption)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
}
.padding(.horizontal)
.padding(.vertical, 10)
.tint(AppTheme.green)
}
/// .
/// (dayKey) " " startOfDay
/// · ( , §4.1).
/// (dayKey(for: 12:00)) .
private var calendarSelection: Binding<Date> {
Binding {
selectedDayKey
} set: { picked in
selectedDayKey = math.calendar.startOfDay(for: picked)
showingCalendar = false
}
}
/// .
/// '' (7) / .
/// (· , / )
private func moveDay(_ direction: Int) {
let step = (mode == .timetable && timetableWeekly) ? 7 : 1
selectedDayKey = math.calendar.date(byAdding: .day, value: direction * step, to: selectedDayKey)!
}
}
// MARK: - · ( DB )
/// **** .
/// TimeSession/CountEntry @Query ,
/// · .
/// - : () (/) .
/// - : · (endAt nil)
/// " < AND ( OR > )" .
private struct HistoryRecordsView: View {
let dayKey: Date
let mode: HistoryMode
@Binding var timetableWeekly: Bool
let excludedActionIDs: Set<PersistentIdentifier>
let orderedActions: [Action]
@Binding var showingExport: Bool
let onTapSession: (TimeSession) -> Void
let onTapEntry: (CountEntry) -> Void
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
private let math = DayMath()
init(dayKey: Date, mode: HistoryMode, timetableWeekly: Binding<Bool>,
excludedActionIDs: Set<PersistentIdentifier>, orderedActions: [Action],
showingExport: Binding<Bool>,
onTapSession: @escaping (TimeSession) -> Void,
onTapEntry: @escaping (CountEntry) -> Void) {
self.dayKey = dayKey
self.mode = mode
self._timetableWeekly = timetableWeekly
self.excludedActionIDs = excludedActionIDs
self.orderedActions = orderedActions
self._showingExport = showingExport
self.onTapSession = onTapSession
self.onTapEntry = onTapEntry
let math = DayMath()
let week = math.weekRange(containing: math.dayRange(forKey: dayKey).lowerBound)
let lower = week.lowerBound
let upper = week.upperBound
let farFuture = Date.distantFuture
_sessions = Query(filter: #Predicate<TimeSession> {
$0.startAt < upper && ($0.endAt ?? farFuture) > lower
})
_entries = Query(filter: #Predicate<CountEntry> {
$0.timestamp >= lower && $0.timestamp < upper
})
}
private var dayRange: Range<Date> { math.dayRange(forKey: dayKey) }
var body: some View {
Group {
switch mode {
case .list:
listView
case .timetable:
TimetableView(
weekly: $timetableWeekly,
selectedDayKey: dayKey,
segments: segments(in:),
counts: counts(in:),
math: math,
onTapSession: onTapSession,
onTapEntry: onTapEntry
)
}
}
.sheet(isPresented: $showingExport) {
// / /
// ( )
ExportImageSheet(snapshot: ExportBuilder.history(
dayKey: dayKey,
weeklyTimetable: mode == .timetable && timetableWeekly,
sessions: sessions,
entries: entries,
orderedActions: orderedActions,
excludedActionIDs: excludedActionIDs,
math: math
))
}
}
private func passesFilter(_ action: Action) -> Bool {
!excludedActionIDs.contains(action.persistentModelID)
}
// MARK: ( )
private func segments(in range: Range<Date>) -> [SessionSegmentItem] {
let now = Date.now
var items: [SessionSegmentItem] = []
for session in sessions {
guard let action = session.action, passesFilter(action) else { continue }
let end = session.endAt ?? now
let lower = max(session.startAt, range.lowerBound)
let upper = min(end, range.upperBound)
guard lower < upper else { continue }
items.append(SessionSegmentItem(session: session, action: action, range: lower..<upper))
}
return items.sorted { $0.range.lowerBound < $1.range.lowerBound }
}
private func counts(in range: Range<Date>) -> [CountItem] {
entries
.filter { range.contains($0.timestamp) }
.compactMap { entry in
entry.action.map { CountItem(entry: entry, action: $0) }
}
.filter { passesFilter($0.action) }
.sorted { $0.entry.timestamp < $1.entry.timestamp }
}
// MARK:
private var listView: some View {
let daySegments = segments(in: dayRange)
let dayCounts = counts(in: dayRange)
return List {
Section("시간 기록") {
if daySegments.isEmpty {
Text("시간 기록이 없어요").foregroundStyle(.secondary)
}
ForEach(daySegments) { item in
Button {
onTapSession(item.session)
} label: {
HStack(spacing: 10) {
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 1) {
Text(item.action.name)
.font(.subheadline.weight(.medium))
Text("\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? String(localized: "진행 중") : Format.time(item.range.upperBound))")
.font(.caption)
.foregroundStyle(.secondary)
if !item.session.note.isEmpty {
Label(item.session.note, systemImage: "note.text")
.font(.caption)
.foregroundStyle(AppTheme.yellow)
.lineLimit(2)
.padding(.top, 1)
}
}
Spacer()
Text(Format.durationShort(item.duration))
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
Section("횟수 기록") {
if dayCounts.isEmpty {
Text("횟수 기록이 없어요").foregroundStyle(.secondary)
}
ForEach(dayCounts) { item in
Button {
onTapEntry(item.entry)
} label: {
HStack(spacing: 10) {
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 1) {
Text(item.action.name)
.font(.subheadline.weight(.medium))
Text(Format.time(item.entry.timestamp))
.font(.caption)
.foregroundStyle(.secondary)
if !item.entry.note.isEmpty {
Label(item.entry.note, systemImage: "note.text")
.font(.caption)
.foregroundStyle(AppTheme.yellow)
.lineLimit(2)
.padding(.top, 1)
}
}
Spacer()
Text("+\(item.entry.amount)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
.scrollContentBackground(.hidden)
}
}
// MARK: -
struct TimetableView: View {
@Binding var weekly: Bool
let selectedDayKey: Date
let segments: (Range<Date>) -> [SessionSegmentItem]
let counts: (Range<Date>) -> [CountItem]
let math: DayMath
let onTapSession: (TimeSession) -> Void
let onTapEntry: (CountEntry) -> Void
private var hourHeight: CGFloat { weekly ? 26 : 48 }
var body: some View {
VStack(spacing: 8) {
Picker("범위", selection: $weekly) {
Text("하루").tag(false)
Text("일주일").tag(true)
}
.pickerStyle(.segmented)
.padding(.horizontal)
ScrollView {
HStack(alignment: .top, spacing: 4) {
hourLabels
if weekly {
weekColumns
} else {
dayColumn(for: selectedDayKey)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal)
.padding(.bottom, 20)
}
}
}
private var startHour: Int {
let start = math.dayRange(forKey: selectedDayKey).lowerBound
return math.calendar.component(.hour, from: start)
}
private var hourLabels: some View {
// ( 06:30) 06:30~07:30 (hour)
// 59 ( )
let startMinute = math.calendar.component(.minute, from: math.dayRange(forKey: selectedDayKey).lowerBound)
return VStack(alignment: .trailing, spacing: 0) {
ForEach(0..<24, id: \.self) { i in
Text(startMinute == 0
? String(format: "%02d", (startHour + i) % 24)
: String(format: "%02d:%02d", (startHour + i) % 24, startMinute))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(height: hourHeight, alignment: .top)
}
}
.frame(width: startMinute == 0 ? 26 : 36)
}
private var weekColumns: some View {
let weekRange = math.weekRange(containing: math.dayRange(forKey: selectedDayKey).lowerBound)
let keys = math.dayKeys(in: weekRange)
return HStack(alignment: .top, spacing: 3) {
ForEach(keys, id: \.self) { key in
VStack(spacing: 2) {
Text(Format.weekdayShort(math.calendar.component(.weekday, from: key)))
.font(.caption2)
.foregroundStyle(key == selectedDayKey ? AppTheme.green : .secondary)
dayColumn(for: key)
}
.frame(maxWidth: .infinity)
}
}
}
private func dayColumn(for key: Date) -> some View {
let range = math.dayRange(forKey: key)
let daySegments = segments(range)
let dayCounts = counts(range)
return GeometryReader { geo in
ZStack(alignment: .topLeading) {
gridLines(width: geo.size.width)
ForEach(daySegments) { item in
block(item, in: range, width: geo.size.width)
}
ForEach(Array(dayCounts.enumerated()), id: \.element.id) { index, item in
marker(item, in: range, width: geo.size.width, index: index)
}
}
}
.frame(height: hourHeight * 24)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func gridLines(width: CGFloat) -> some View {
VStack(spacing: 0) {
ForEach(0..<24, id: \.self) { _ in
Divider().opacity(0.4)
Spacer(minLength: 0)
}
}
.frame(width: width, height: hourHeight * 24)
}
private func yOffset(for date: Date, in range: Range<Date>) -> CGFloat {
let seconds = date.timeIntervalSince(range.lowerBound)
return CGFloat(seconds / 3600) * hourHeight
}
private func block(_ item: SessionSegmentItem, in range: Range<Date>, width: CGFloat) -> some View {
let y = yOffset(for: item.range.lowerBound, in: range)
let height = max(CGFloat(item.duration / 3600) * hourHeight, 8)
return Button {
onTapSession(item.session)
} label: {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(item.action.color.opacity(0.85))
.overlay(alignment: .topLeading) {
if !weekly && height >= 18 {
Label(item.action.name, systemImage: SymbolCompat.safe(item.action.symbolName))
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(4)
} else if height >= 14 {
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
}
}
}
.buttonStyle(.plain)
//
.accessibilityLabel(Text(verbatim:
"\(item.action.name) \(Format.time(item.range.lowerBound)) ~ \(Format.time(item.range.upperBound))"))
.frame(width: max(width - 4, 8), height: height)
.offset(x: 2, y: y)
}
private func marker(_ item: CountItem, in range: Range<Date>, width: CGFloat, index: Int) -> some View {
let y = yOffset(for: item.entry.timestamp, in: range)
let size: CGFloat = weekly ? 10 : 18
let x = 4 + CGFloat(index % 5) * (size + 4)
return Button {
onTapEntry(item.entry)
} label: {
ZStack {
Circle()
.fill(item.action.color)
Circle()
.strokeBorder(AppTheme.surface, lineWidth: 2)
if !weekly {
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
}
}
.frame(width: size, height: size)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(verbatim:
"\(item.action.name) +\(item.entry.amount) \(Format.time(item.entry.timestamp))"))
.offset(x: min(x, width - size), y: max(y - size / 2, 0))
}
}