mycode/myApp/HaruDanim/IOS/Views/HistoryView.swift
songyc macbook a02e06a9df feat: add reordering to floating radial menu and fix weekly timetable step
Radial menu icon order (iPhone floating nav only):
- Settings → 내비게이션 gains a '반원 메뉴 순서 편집' link (shown only when the
  radial style is selected) that opens a drag-to-reorder list of all 7 tabs.
- Order is stored device-local via @AppStorage(SettingsKeys.radialTabOrder)
  (not CloudKit-synced); RadialMenu reads it through AppTab.radialOrder(from:),
  which always yields the full tab set and appends any missing/unknown value in
  default spec order — so partial or legacy strings can never drop a tab.
- iPad/Mac sidebars and the watch target are untouched (they never read the key).

History timetable date step:
- The date header </> buttons now jump by a full week (7 days) when the
  timetable is in '일주일' view, and by 1 day otherwise (list / daily timetable).
- The calendar popover and '오늘' still set the date directly, so those paths and
  record loading are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-12 09:13:20 +09:00

577 lines
22 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 private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
@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 = DayMath().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 = false
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@State private var showingCalendar = false
@State private var showingFilter = 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
}
switch mode {
case .list:
listView
case .timetable:
TimetableView(
weekly: $timetableWeekly,
selectedDayKey: selectedDayKey,
segments: segments(in:),
counts: counts(in:),
math: math,
onTapSession: { editingSession = $0 },
onTapEntry: { editingEntry = $0 }
)
}
}
.background(AppTheme.background)
.navigationTitle("기록")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingFilter = true
} label: {
Image(systemName: isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
}
}
.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
}
#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 {
!excludedActionIDs.isEmpty
}
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: ", ")
: "행동 \(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)
}
.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())
}
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())
}
Button("오늘") {
selectedDayKey = math.dayKey(for: .now)
}
.font(.caption)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
}
.padding(.horizontal)
.padding(.vertical, 10)
.tint(AppTheme.green)
}
/// ( )
private var calendarSelection: Binding<Date> {
Binding {
selectedDayKey
} set: { picked in
let cal = math.calendar
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: picked) ?? picked
selectedDayKey = math.dayKey(for: noon)
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: ( )
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 {
editingSession = item.session
} label: {
HStack(spacing: 10) {
Image(systemName: 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 ? "진행 중" : 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 {
editingEntry = item.entry
} label: {
HStack(spacing: 10) {
Image(systemName: 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 {
VStack(alignment: .trailing, spacing: 0) {
ForEach(0..<24, id: \.self) { i in
Text(String(format: "%02d", (startHour + i) % 24))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(height: hourHeight, alignment: .top)
}
}
.frame(width: 26)
}
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: item.action.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(4)
} else if height >= 14 {
Image(systemName: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
}
}
}
.buttonStyle(.plain)
.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(systemName: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
}
}
.frame(width: size, height: size)
}
.buttonStyle(.plain)
.offset(x: min(x, width - size), y: max(y - size / 2, 0))
}
}