'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.
[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
(이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
— 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
in: 은 표시 제약만) — 저장 시 정규화
+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)
검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
708 lines
28 KiB
Swift
708 lines
28 KiB
Swift
//
|
||
// 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 = 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: 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(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 ? 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(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 {
|
||
// 하루 시작이 정시가 아니면(예 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: 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)
|
||
// 색 블록만으로는 보이스오버가 읽을 내용이 없다 — 행동 이름과 시간대를 라벨로
|
||
.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(systemName: 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))
|
||
}
|
||
}
|
||
|