- 언어 설정 실적용: 변경 시 AppleLanguages 오버라이드 저장, 재시작 안내 footer
- 자동 추출되지 않던 일반 문자열(탭 이름, 픽커 라벨, 상태 뱃지, 포매터,
삼항 문구, 심볼 카테고리 등)을 String(localized:)로 전환
- 위젯·워치 앱·컴플리케이션 타깃에 Localizable.xcstrings 신설
(중복 리소스 방지 pbxproj 예외 추가), xcstringstool sync로 키 추출
- 4개 카탈로그 전체 키(고유 430개)에 en/ja 번역 주입
- 영어의 긴 횟수 단위("times")가 잘리지 않도록 모음 탭 셀 축소 허용
- 검증: -AppleLanguages "(en)"/"(ja)"로 모음 탭·설정 화면 확인
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
566 lines
21 KiB
Swift
566 lines
21 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 private var sessions: [TimeSession]
|
|
@Query private var entries: [CountEntry]
|
|
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
|
@Query(sort: \Action.sortOrder) private var allActions: [Action]
|
|
|
|
@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
|
|
}
|
|
}
|
|
|
|
private func moveDay(_ delta: Int) {
|
|
selectedDayKey = math.calendar.date(byAdding: .day, value: delta, 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))
|
|
}
|
|
}
|
|
|