diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 9bfee64..9af8754 100644 Binary files a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/Haru_Danim/IOS/ContentView.swift b/myApp/Haru_Danim/IOS/ContentView.swift index 6c33c7a..d2e0b19 100644 --- a/myApp/Haru_Danim/IOS/ContentView.swift +++ b/myApp/Haru_Danim/IOS/ContentView.swift @@ -73,10 +73,37 @@ struct SplashView: View { } } +// MARK: - 탭 간 이동 라우터 + +/// 탭 전환과 탭 간 전달값 관리 (예: 모음 탭 '기록 확인' → 기록 탭 행동 필터) +@Observable +final class AppRouter { + var tabSelection: String = { + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "startTab") { + return raw + } + #endif + return AppTab.main.rawValue + }() + + /// 기록 탭이 열릴 때 적용할 행동 필터 (기록 탭에서 소비 후 nil로 되돌림) + var pendingHistoryActionID: PersistentIdentifier? + + /// 기록 탭으로 이동하며 행동 필터를 예약. 기록 탭이 노출 탭에 없으면 더보기로 이동. + func openHistory(filtering actionID: PersistentIdentifier, visibleTabsRaw: String) { + pendingHistoryActionID = actionID + let visible = AppTab.visibleTabs(from: visibleTabsRaw) + tabSelection = visible.contains(.history) ? AppTab.history.rawValue : AppTab.moreTabValue + } +} + // MARK: - 탭 정의 enum AppTab: String, CaseIterable, Identifiable { - case main, action, tag, goal, history, settings + case main, action, tag, goal, history, stats, settings + + static let moreTabValue = "more" var id: String { rawValue } @@ -87,6 +114,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .tag: return "꼬리표" case .goal: return "목표" case .history: return "기록" + case .stats: return "통계" case .settings: return "설정" } } @@ -98,6 +126,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .tag: return "tag.fill" case .goal: return "flag.checkered" case .history: return "calendar" + case .stats: return "chart.xyaxis.line" case .settings: return "gearshape.fill" } } @@ -117,6 +146,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .tag: TagListView() case .goal: GoalListView() case .history: HistoryView() + case .stats: StatsTabView() case .settings: SettingsView() } } @@ -127,16 +157,7 @@ enum AppTab: String, CaseIterable, Identifiable { struct MainTabView: View { @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs - private static let moreTabValue = "more" - - @State private var selection: String = { - #if DEBUG - if let raw = UserDefaults.standard.string(forKey: "startTab") { - return raw - } - #endif - return AppTab.main.rawValue - }() + @State private var router = AppRouter() private var visibleTabs: [AppTab] { AppTab.visibleTabs(from: visibleTabsRaw) @@ -147,7 +168,7 @@ struct MainTabView: View { } var body: some View { - TabView(selection: $selection) { + TabView(selection: $router.tabSelection) { ForEach(visibleTabs) { tab in Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) { NavigationStack { @@ -155,23 +176,24 @@ struct MainTabView: View { } } } - Tab("더보기", systemImage: "ellipsis", value: Self.moreTabValue) { + Tab("더보기", systemImage: "ellipsis", value: AppTab.moreTabValue) { MoreTabView(tabs: moreTabs) } } + .environment(router) .onChange(of: visibleTabsRaw) { // 노출 탭에서 빠진 탭이 선택돼 있으면 더보기로 이동 - let valid = visibleTabs.map(\.rawValue) + [Self.moreTabValue] - if !valid.contains(selection) { - selection = Self.moreTabValue + let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] + if !valid.contains(router.tabSelection) { + router.tabSelection = AppTab.moreTabValue } } .onAppear { #if DEBUG // 검증용: -startTab이 노출 탭에 없으면 더보기 대신 그 탭 화면으로 안내되도록 보정 - let valid = visibleTabs.map(\.rawValue) + [Self.moreTabValue] - if !valid.contains(selection) { - selection = Self.moreTabValue + let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] + if !valid.contains(router.tabSelection) { + router.tabSelection = AppTab.moreTabValue } #endif } diff --git a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift index 962f064..127785f 100644 --- a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift +++ b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift @@ -46,7 +46,10 @@ enum DebugSeed { let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4) water.tags = [life] water.promptsForNote = true - for action in [reading, english, running, pushup, water] { + // '이하 유지' 다짐 검증용 (하루 1시간 이하 목표, 일부러 초과하는 날 포함) + let video = Action(name: "영상 시청", symbolName: "play.rectangle.fill", trackingType: .time, sortOrder: 5) + video.tags = [life] + for action in [reading, english, running, pushup, water, video] { context.insert(action) } @@ -66,6 +69,12 @@ enum DebugSeed { if daysAgo % 3 != 0 { context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15))) } + // 영상 시청: 짝수 날은 40분(1시간 이하 유지), 홀수 날은 1시간 30분(초과) + if daysAgo % 2 == 0 { + context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 13, minute: 10))) + } else { + context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 14, minute: 0))) + } } // 자정을 걸치는 세션 (하루 경계 분할 확인용) + 메모 표시 확인용 let crossing = TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40)) @@ -96,7 +105,6 @@ enum DebugSeed { startDate: cal.date(byAdding: .day, value: -10, to: now)!, endDate: cal.date(byAdding: .day, value: 30, to: now)! ) - toeic.showsOnMain = true context.insert(toeic) let englishQuest = Quest(goal: toeic) englishQuest.targetAction = english @@ -113,6 +121,8 @@ enum DebugSeed { startDate: cal.date(byAdding: .day, value: -20, to: now)!, endDate: nil ) + // 다짐 3개(이하 유지 포함)라 모음 탭 목표 카드 검증에 적합 + health.showsOnMain = true context.insert(health) let waterQuest = Quest(goal: health) waterQuest.targetAction = water @@ -130,6 +140,14 @@ enum DebugSeed { runQuest.targetSeconds = 30 * 60 runQuest.direction = .atLeast context.insert(runQuest) + // '이하 유지' 다짐: 영상 시청 하루 1시간 이하 (달성률 로직 검증용) + let videoQuest = Quest(goal: health) + videoQuest.targetAction = video + videoQuest.measure = .time + videoQuest.period = .daily + videoQuest.targetSeconds = 3600 + videoQuest.direction = .atMost + context.insert(videoQuest) } } #endif diff --git a/myApp/Haru_Danim/IOS/Core/QuestProgress.swift b/myApp/Haru_Danim/IOS/Core/QuestProgress.swift index 239dde9..025b205 100644 --- a/myApp/Haru_Danim/IOS/Core/QuestProgress.swift +++ b/myApp/Haru_Danim/IOS/Core/QuestProgress.swift @@ -27,10 +27,26 @@ struct QuestProgressResult { let target: Double let direction: QuestDirection - /// 게이지 표시용 비율 (0...1) + /// 게이지 표시용 비율 (0...1). + /// '이하 유지'는 기준을 넘지 않는 동안 100%(달성 유지), 초과하는 순간 0%(실패)로 취급. var ratio: Double { - guard target > 0 else { return 0 } - return min(value / target, 1) + switch direction { + case .atLeast: + guard target > 0 else { return 0 } + return min(value / target, 1) + case .atMost: + return value <= target ? 1 : 0 + } + } + + /// 퍼센트 문구 표시용 값. '이상 달성'은 100%를 넘을 수 있고, '이하 유지'는 100% 또는 0%. + var displayRatio: Double { + switch direction { + case .atLeast: + return target > 0 ? value / target : 0 + case .atMost: + return value <= target ? 1 : 0 + } } /// 현재 시점 기준 달성 여부 diff --git a/myApp/Haru_Danim/IOS/Core/Settings.swift b/myApp/Haru_Danim/IOS/Core/Settings.swift index 5eb1ad2..6afd6ff 100644 --- a/myApp/Haru_Danim/IOS/Core/Settings.swift +++ b/myApp/Haru_Danim/IOS/Core/Settings.swift @@ -26,6 +26,25 @@ enum SettingsKeys { static let minSessionSeconds = "settings.minSessionSeconds" /// 탭바에 직접 노출할 탭(더보기 제외 1~3개). AppTab rawValue를 쉼표로 연결한 문자열 static let visibleTabs = "settings.visibleTabs" + /// 모음 탭 목표 카드 표시 방식. GoalCardStyle rawValue ("perQuest" | "combined") + static let goalCardStyle = "settings.goalCardStyle" +} + +/// 모음 탭 목표 진행 현황 카드의 표시 방식 +enum GoalCardStyle: String, CaseIterable, Identifiable { + /// 다짐(최대 3개)별로 하루/주간/월간 진행률을 각각 표시 + case perQuest + /// 소속 다짐 전체의 하루/주간/월간 진행률을 합산(평균)해 한 번에 표시 + case combined + + var id: String { rawValue } + + var label: String { + switch self { + case .perQuest: return "다짐별로 각각" + case .combined: return "전체 다짐 합산" + } + } } enum TabBarConfig { diff --git a/myApp/Haru_Danim/IOS/Localizable.xcstrings b/myApp/Haru_Danim/IOS/Localizable.xcstrings index d1ad832..af0e114 100644 --- a/myApp/Haru_Danim/IOS/Localizable.xcstrings +++ b/myApp/Haru_Danim/IOS/Localizable.xcstrings @@ -179,9 +179,6 @@ }, "누적 기록" : { - }, - "누적 횟수" : { - }, "다이나믹 아일랜드 · 잠금화면" : { @@ -269,7 +266,7 @@ "모음 탭" : { }, - "목록·타임테이블·통계 모두에 필터가 적용돼요." : { + "목록과 타임테이블 모두에 필터가 적용돼요." : { }, "목표" : { @@ -374,11 +371,14 @@ }, "선택" : { + }, + "선택한 꼬리표·행동의 기록만 통계에 반영돼요." : { + }, "선택한 꼬리표가 붙은 행동의 기록만 표시돼요." : { }, - "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요." : { + "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요." : { }, "선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : { @@ -527,6 +527,9 @@ }, "주기마다 목표량을 넘지 않는 것이 목표예요." : { + }, + "주차" : { + }, "즐겨찾기" : { @@ -581,14 +584,17 @@ }, "테마" : { + }, + "통계" : { + + }, + "통계 필터" : { + }, "특정 시각 지정" : { }, - "평균" : { - - }, - "평균 %@" : { + "표시 방식" : { }, "표시 안 함" : { @@ -637,9 +643,22 @@ }, "한 줄에 표시할 개수" : { + }, + "해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : { + }, "행동" : { + }, + "행동 %lld/%lld개 표시 중" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "행동 %1$lld/%2$lld개 표시 중" + } + } + } }, "행동 %lld개" : { diff --git a/myApp/Haru_Danim/IOS/Views/GoalViews.swift b/myApp/Haru_Danim/IOS/Views/GoalViews.swift index 62bbf76..21a3827 100644 --- a/myApp/Haru_Danim/IOS/Views/GoalViews.swift +++ b/myApp/Haru_Danim/IOS/Views/GoalViews.swift @@ -219,7 +219,7 @@ struct QuestRow: View { .foregroundStyle(.secondary) ProgressView(value: result.ratio) .tint(over ? .red : quest.targetColor) - Text(Format.percent(result.target > 0 ? result.value / result.target : 0)) + Text(Format.percent(result.displayRatio)) .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(over ? .red : .primary) } diff --git a/myApp/Haru_Danim/IOS/Views/HistoryView.swift b/myApp/Haru_Danim/IOS/Views/HistoryView.swift index d3e142b..8afc7a5 100644 --- a/myApp/Haru_Danim/IOS/Views/HistoryView.swift +++ b/myApp/Haru_Danim/IOS/Views/HistoryView.swift @@ -2,15 +2,14 @@ // HistoryView.swift // Haru_Danim // -// 기록 탭: 일간 목록 / 타임테이블(하루·일주일) / 통계 (CLAUDE.md §6.5) +// 기록 탭: 일간 목록 / 타임테이블(하루·일주일) (CLAUDE.md §6.5 — 통계는 통계 탭으로 분리) // import SwiftUI import SwiftData -import Charts enum HistoryMode: String, CaseIterable, Identifiable { - case list, timetable, stats + case list, timetable var id: String { rawValue } @@ -18,7 +17,6 @@ enum HistoryMode: String, CaseIterable, Identifiable { switch self { case .list: return "목록" case .timetable: return "타임테이블" - case .stats: return "통계" } } } @@ -47,6 +45,8 @@ struct CountItem: Identifiable { // 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] @@ -63,15 +63,6 @@ struct HistoryView: View { return .list }() @State private var timetableWeekly = false - @State private var statSpan: StatSpan = { - #if DEBUG - if let raw = UserDefaults.standard.string(forKey: "statSpan"), - let span = StatSpan(rawValue: raw) { - return span - } - #endif - return .day - }() @State private var editingSession: TimeSession? @State private var editingEntry: CountEntry? @State private var showingCalendar = false @@ -111,14 +102,6 @@ struct HistoryView: View { onTapSession: { editingSession = $0 }, onTapEntry: { editingEntry = $0 } ) - case .stats: - StatsView( - statSpan: $statSpan, - selectedDayKey: selectedDayKey, - segments: segments(in:), - counts: counts(in:), - math: math - ) } } .background(AppTheme.background) @@ -149,6 +132,20 @@ struct HistoryView: View { filterActionIDs: $filterActionIDs ) } + .onAppear(perform: consumePendingFilter) + .onChange(of: router.pendingHistoryActionID) { + consumePendingFilter() + } + } + + /// 모음 탭 '기록 확인'에서 예약한 행동 필터를 적용 (오늘 날짜의 목록 뷰로 전환) + private func consumePendingFilter() { + guard let id = router.pendingHistoryActionID else { return } + filterTag = nil + filterActionIDs = [id] + mode = .list + selectedDayKey = math.dayKey(for: .now) + router.pendingHistoryActionID = nil } // MARK: 필터 (꼬리표 1개 또는 행동 여러 개) @@ -566,346 +563,6 @@ struct TimetableView: View { } } -// MARK: - 통계 뷰 (Swift Charts) - -struct TagStat: Identifiable { - let name: String - let color: Color - let seconds: TimeInterval - let count: Int - - var id: String { name } -} - -struct DailyTagStat: Identifiable { - let dayKey: Date - let tagName: String - let color: Color - let hours: Double - - var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(tagName)" } -} - -struct StatsView: View { - @Binding var statSpan: StatSpan - let selectedDayKey: Date - let segments: (Range) -> [SessionSegmentItem] - let counts: (Range) -> [CountItem] - let math: DayMath - - private var spanRange: Range { - let anchor = math.dayRange(forKey: selectedDayKey).lowerBound - switch statSpan { - case .day: return math.dayRange(containing: anchor) - case .week: return math.weekRange(containing: anchor) - case .month: return math.monthRange(containing: anchor) - } - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - Picker("기간", selection: $statSpan) { - ForEach(StatSpan.allCases) { span in - Text(span.label).tag(span) - } - } - .pickerStyle(.segmented) - - let stats = tagStats(in: spanRange) - timeChartCard(stats) - countChartCard(stats) - if statSpan != .day { - let points = trendPoints() - timeTrendCard(points) - countTrendCard(points) - } - weeklyTrendCard - } - .padding() - } - } - - // MARK: 태그별 집계 - - private func tagStats(in range: Range) -> [TagStat] { - var seconds: [String: TimeInterval] = [:] - var countsByTag: [String: Int] = [:] - var colors: [String: Color] = [:] - - func tagNames(for action: Action) -> [(String, Color)] { - if action.tags.isEmpty { return [("꼬리표 없음", Color.gray)] } - return action.sortedTags.map { ($0.name, $0.color) } - } - - for item in segments(range) { - for (name, color) in tagNames(for: item.action) { - seconds[name, default: 0] += item.duration - colors[name] = color - } - } - for item in counts(range) { - for (name, color) in tagNames(for: item.action) { - countsByTag[name, default: 0] += item.entry.amount - colors[name] = color - } - } - - let names = Set(seconds.keys).union(countsByTag.keys) - return names - .map { name in - TagStat( - name: name, - color: colors[name] ?? .gray, - seconds: seconds[name] ?? 0, - count: countsByTag[name] ?? 0 - ) - } - .sorted { $0.seconds > $1.seconds } - } - - // MARK: 차트 카드 - - @ViewBuilder - private func timeChartCard(_ stats: [TagStat]) -> some View { - let timeStats = stats.filter { $0.seconds > 0 } - chartCard("꼬리표별 시간 투자") { - if timeStats.isEmpty { - emptyChartText - } else { - Chart(timeStats) { stat in - BarMark( - x: .value("시간", stat.seconds / 3600), - y: .value("꼬리표", stat.name) - ) - .foregroundStyle(stat.color) - .cornerRadius(4) - .annotation(position: .trailing) { - Text(Format.durationShort(stat.seconds)) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .chartXAxisLabel("시간(h)") - .frame(height: CGFloat(timeStats.count) * 44 + 30) - } - } - } - - @ViewBuilder - private func countChartCard(_ stats: [TagStat]) -> some View { - let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count } - chartCard("꼬리표별 횟수 달성") { - if countStats.isEmpty { - emptyChartText - } else { - Chart(countStats) { stat in - BarMark( - x: .value("횟수", stat.count), - y: .value("꼬리표", stat.name) - ) - .foregroundStyle(stat.color) - .cornerRadius(4) - .annotation(position: .trailing) { - Text("\(stat.count)회") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .chartXAxisLabel("횟수") - .frame(height: CGFloat(countStats.count) * 44 + 30) - } - } - } - - // MARK: 추세선 (주간/월간, 꺾은선 그래프 + 평균) - - private struct TrendPoint: Identifiable { - let dayKey: Date - let hours: Double - let count: Int - let cumulativeCount: Int - - var id: Date { dayKey } - } - - /// 선택 기간의 하루별 합산 시간과 누적 횟수 - private func trendPoints() -> [TrendPoint] { - var cumulative = 0 - return math.dayKeys(in: spanRange).map { key in - let range = math.dayRange(forKey: key) - let seconds = segments(range).reduce(0.0) { $0 + $1.duration } - let count = counts(range).reduce(0) { $0 + $1.entry.amount } - cumulative += count - return TrendPoint(dayKey: key, hours: seconds / 3600, count: count, cumulativeCount: cumulative) - } - } - - /// 평균 계산에 쓸 "이미 지나간(시작된)" 날 수 — 이번 주/이번 달의 미래 날짜로 평균이 희석되지 않게 - private func elapsedDayCount(_ points: [TrendPoint]) -> Int { - max(points.filter { math.dayRange(forKey: $0.dayKey).lowerBound <= .now }.count, 1) - } - - @ViewBuilder - private func timeTrendCard(_ points: [TrendPoint]) -> some View { - let totalHours = points.reduce(0.0) { $0 + $1.hours } - let avgHours = totalHours / Double(elapsedDayCount(points)) - chartCard("시간 추세 (하루 합산)") { - if totalHours == 0 { - emptyChartText - } else { - Chart { - ForEach(points) { point in - LineMark( - x: .value("날짜", point.dayKey, unit: .day), - y: .value("시간", point.hours) - ) - .foregroundStyle(AppTheme.green) - .interpolationMethod(.monotone) - .symbol(.circle) - } - RuleMark(y: .value("평균", avgHours)) - .foregroundStyle(AppTheme.yellow) - .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [5, 4])) - .annotation(position: .top, alignment: .trailing) { - Text("평균 \(Format.durationShort(avgHours * 3600))") - .font(.caption2.weight(.semibold)) - .foregroundStyle(AppTheme.yellow) - } - } - .chartYAxisLabel("시간(h)") - .frame(height: 180) - averageRow( - total: Format.durationShort(totalHours * 3600), - average: Format.durationShort(avgHours * 3600) - ) - } - } - } - - @ViewBuilder - private func countTrendCard(_ points: [TrendPoint]) -> some View { - let totalCount = points.last?.cumulativeCount ?? 0 - let avgCount = Double(totalCount) / Double(elapsedDayCount(points)) - chartCard("횟수 추세 (누적)") { - if totalCount == 0 { - emptyChartText - } else { - Chart { - ForEach(points) { point in - LineMark( - x: .value("날짜", point.dayKey, unit: .day), - y: .value("누적 횟수", point.cumulativeCount) - ) - .foregroundStyle(AppTheme.green) - .interpolationMethod(.monotone) - .symbol(.circle) - } - } - .chartYAxisLabel("누적 횟수") - .frame(height: 180) - averageRow( - total: "\(totalCount)회", - average: String(format: "%.1f회", avgCount) - ) - } - } - } - - /// 그래프 아래 요약: 기간 합계 · 하루 평균 - private func averageRow(total: String, average: String) -> some View { - HStack(spacing: 0) { - summaryCell(label: "\(statSpan.label) 합계", value: total) - Divider().frame(height: 28) - summaryCell(label: "하루 평균", value: average) - } - .padding(.top, 4) - } - - private func summaryCell(label: String, value: String) -> some View { - VStack(spacing: 2) { - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - Text(value) - .font(.subheadline.weight(.semibold).monospacedDigit()) - .foregroundStyle(AppTheme.green) - } - .frame(maxWidth: .infinity) - } - - /// 최근 7일 꼬리표별 시간 추이 (스택 막대) - private var weeklyTrendCard: some View { - let data = recentDailyStats() - let names = orderedNames(from: data) - let colors = orderedColors(from: data, names: names) - return chartCard("최근 7일 시간 추이") { - if data.isEmpty { - emptyChartText - } else { - Chart(data) { item in - BarMark( - x: .value("날짜", item.dayKey, unit: .day), - y: .value("시간", item.hours) - ) - .foregroundStyle(by: .value("꼬리표", item.tagName)) - .cornerRadius(3) - } - .chartForegroundStyleScale(domain: names, range: colors) - .chartYAxisLabel("시간(h)") - .frame(height: 220) - } - } - } - - private func recentDailyStats() -> [DailyTagStat] { - var result: [DailyTagStat] = [] - for offset in (0..<7).reversed() { - guard let key = math.calendar.date(byAdding: .day, value: -offset, to: selectedDayKey) else { continue } - let range = math.dayRange(forKey: key) - for stat in tagStats(in: range) where stat.seconds > 0 { - result.append(DailyTagStat( - dayKey: key, - tagName: stat.name, - color: stat.color, - hours: stat.seconds / 3600 - )) - } - } - return result - } - - private func orderedNames(from data: [DailyTagStat]) -> [String] { - var seen = Set() - return data.map(\.tagName).filter { seen.insert($0).inserted } - } - - private func orderedColors(from data: [DailyTagStat], names: [String]) -> [Color] { - names.map { name in - data.first { $0.tagName == name }?.color ?? .gray - } - } - - private func chartCard(_ title: String, @ViewBuilder content: () -> some View) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.subheadline.weight(.semibold)) - content() - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - - private var emptyChartText: some View { - Text("표시할 기록이 없어요") - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - } -} - // MARK: - 기록 필터 시트 (꼬리표 1개 또는 행동 다중 선택) struct HistoryFilterSheet: View { @@ -965,7 +622,7 @@ struct HistoryFilterSheet: View { } header: { Text("행동으로 보기 (여러 개 선택 가능)") } footer: { - Text("목록·타임테이블·통계 모두에 필터가 적용돼요.") + Text("목록과 타임테이블 모두에 필터가 적용돼요.") } } .scrollContentBackground(.hidden) diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift index 6a946e4..2383cc3 100644 --- a/myApp/Haru_Danim/IOS/Views/MainView.swift +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -16,8 +16,11 @@ struct MainView: View { private var runningSessions: [TimeSession] @Query(filter: #Predicate { $0.showsOnMain }) private var pinnedGoals: [Goal] + @Environment(AppRouter.self) private var router + @AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3 @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 + @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs @State private var isEditing: Bool = { #if DEBUG @@ -29,7 +32,6 @@ struct MainView: View { @State private var draggingAction: Action? @State private var settingsEditingAction: Action? @State private var recordsAction: Action? - @State private var recordsStartWithAdd = false @State private var deletingAction: Action? @State private var memoSession: TimeSession? @State private var memoEntry: CountEntry? @@ -72,7 +74,7 @@ struct MainView: View { ActionEditorView(action: action) } .sheet(item: $recordsAction) { action in - ActionRecordsSheet(action: action, startWithAdd: recordsStartWithAdd) + ActionRecordsSheet(action: action, startWithAdd: true) } .sheet(item: $memoSession) { session in SessionMemoSheet(session: session) @@ -179,13 +181,15 @@ struct MainView: View { base .contextMenu { Button { - recordsStartWithAdd = false - recordsAction = action + // 기록 탭으로 이동해 이 행동만 필터링된 상태로 표시 + router.openHistory( + filtering: action.persistentModelID, + visibleTabsRaw: visibleTabsRaw + ) } label: { Label("기록 확인", systemImage: "list.bullet.rectangle") } Button { - recordsStartWithAdd = true recordsAction = action } label: { Label( @@ -279,6 +283,12 @@ struct MainView: View { struct GoalSummaryCard: View { let goal: Goal + @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue + + private var cardStyle: GoalCardStyle { + GoalCardStyle(rawValue: cardStyleRaw) ?? .perQuest + } + var body: some View { NavigationLink { GoalDetailView(goal: goal) @@ -313,8 +323,15 @@ struct GoalSummaryCard: View { .foregroundStyle(.secondary) } } - ForEach(goal.sortedQuests.prefix(3)) { quest in - questLine(quest) + if !goal.sortedQuests.isEmpty { + switch cardStyle { + case .perQuest: + ForEach(goal.sortedQuests.prefix(3)) { quest in + questSpanBlock(quest) + } + case .combined: + combinedBlock + } } } .padding(14) @@ -325,26 +342,82 @@ struct GoalSummaryCard: View { .buttonStyle(.plain) } - /// 다짐 한 줄 요약: 현재 주기 진행률 게이지 + 퍼센트 - @ViewBuilder - private func questLine(_ quest: Quest) -> some View { - if let result = QuestProgress(quest: quest).current() { - let over = quest.direction == .atMost && result.value > result.target - HStack(spacing: 8) { + /// 표시 방식 A: 다짐 하나의 하루/주간/월간 진행률을 각각 표시 + private func questSpanBlock(_ quest: Quest) -> some View { + let progress = QuestProgress(quest: quest) + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: quest.targetSymbol) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(quest.targetColor, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) Text(quest.targetName) - .font(.caption) + .font(.caption.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) - .frame(width: 88, alignment: .leading) - ProgressView(value: result.ratio) - .tint(over ? .red : quest.targetColor) - Text(Format.percent(result.target > 0 ? result.value / result.target : 0)) - .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(over ? .red : .secondary) - .frame(width: 40, alignment: .trailing) + } + HStack(spacing: 10) { + ForEach(StatSpan.allCases) { span in + let result = progress.spanProgress(span) + let over = quest.direction == .atMost && result.value > result.target + miniGauge( + label: span.label, + ratio: result.ratio, + percentText: Format.percent(result.displayRatio), + color: over ? .red : quest.targetColor, + emphasized: over + ) + } } } } + + /// 표시 방식 B: 소속 다짐 전체의 하루/주간/월간 진행률 평균을 한 줄로 표시 + private var combinedBlock: some View { + HStack(spacing: 10) { + ForEach(StatSpan.allCases) { span in + let ratio = combinedRatio(span) + miniGauge( + label: "\(span.label) 전체", + ratio: ratio, + percentText: Format.percent(ratio), + color: goal.color, + emphasized: false + ) + } + } + } + + /// 소속 다짐들의 span 진행률 평균 (이하 유지 다짐은 100% 또는 0%로 반영됨) + private func combinedRatio(_ span: StatSpan) -> Double { + let quests = goal.sortedQuests + guard !quests.isEmpty else { return 0 } + let sum = quests.reduce(0.0) { + $0 + QuestProgress(quest: $1).spanProgress(span).ratio + } + return sum / Double(quests.count) + } + + private func miniGauge( + label: String, + ratio: Double, + percentText: String, + color: Color, + emphasized: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + ProgressView(value: min(max(ratio, 0), 1)) + .tint(color) + Text(percentText) + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(emphasized ? .red : .secondary) + } + .frame(maxWidth: .infinity) + } } // MARK: - 측정 종료 메모 시트 diff --git a/myApp/Haru_Danim/IOS/Views/SettingsView.swift b/myApp/Haru_Danim/IOS/Views/SettingsView.swift index 76f147b..625c762 100644 --- a/myApp/Haru_Danim/IOS/Views/SettingsView.swift +++ b/myApp/Haru_Danim/IOS/Views/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage(SettingsKeys.isPremium) private var isPremium = false @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs + @AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue private var visibleTabs: [AppTab] { AppTab.visibleTabs(from: visibleTabsRaw) @@ -75,10 +76,17 @@ struct SettingsView: View { Text(goal.title).tag(Optional(goal.persistentModelID)) } } + if goals.contains(where: \.showsOnMain) { + Picker("표시 방식", selection: $goalCardStyle) { + ForEach(GoalCardStyle.allCases) { style in + Text(style.label).tag(style.rawValue) + } + } + } } header: { Text("모음 탭") } footer: { - Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요.") + Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요.") } Section { Picker("주 시작 요일", selection: $weekStartWeekday) { diff --git a/myApp/Haru_Danim/IOS/Views/StatsTabView.swift b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift new file mode 100644 index 0000000..718d0ab --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift @@ -0,0 +1,737 @@ +// +// StatsTabView.swift +// Haru_Danim +// +// 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터 +// - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교) +// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 +// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 +// + +import SwiftUI +import SwiftData +import Charts + +struct StatsTabView: View { + @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 span: StatSpan = { + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "statSpan"), + let span = StatSpan(rawValue: raw) { + return span + } + #endif + return .day + }() + @State private var anchorDayKey: Date = DayMath().dayKey(for: .now) + @State private var showingFilter = false + /// 필터는 "해제한 항목"만 저장 → 기본값이 전체 선택이고, 새로 만든 행동/꼬리표도 자동 포함됨 + @State private var excludedTagIDs: Set = [] + @State private var excludedActionIDs: Set = [] + + private var math: DayMath { DayMath() } + + private var spanRange: Range { + let anchor = math.dayRange(forKey: anchorDayKey).lowerBound + switch span { + case .day: return math.dayRange(containing: anchor) + case .week: return math.weekRange(containing: anchor) + case .month: return math.monthRange(containing: anchor) + } + } + + var body: some View { + VStack(spacing: 0) { + periodHeader + Picker("기간", selection: $span) { + ForEach(StatSpan.allCases) { span in + Text(span.label).tag(span) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 8) + + if isFiltering { + filterChip + } + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + switch span { + case .day: + dayCharts + case .week: + weekCharts + case .month: + monthCharts + } + } + .padding() + } + } + .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(isPresented: $showingFilter) { + StatsFilterSheet( + tags: tags, + actions: allActions, + excludedTagIDs: $excludedTagIDs, + excludedActionIDs: $excludedActionIDs + ) + } + } + + // MARK: 기간 이동 헤더 + + private var periodHeader: some View { + HStack { + Button { + movePeriod(-1) + } label: { + Image(systemName: "chevron.left") + .frame(width: 44, height: 36) + .contentShape(Rectangle()) + } + Spacer() + VStack(spacing: 1) { + Text(periodLabel) + .font(.headline) + if spanRange.contains(.now) { + Text(currentPeriodLabel) + .font(.caption2) + .foregroundStyle(AppTheme.green) + } + } + Spacer() + Button { + movePeriod(1) + } label: { + Image(systemName: "chevron.right") + .frame(width: 44, height: 36) + .contentShape(Rectangle()) + } + Button(currentPeriodLabel) { + anchorDayKey = math.dayKey(for: .now) + } + .font(.caption) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + } + .padding(.horizontal) + .padding(.vertical, 10) + .tint(AppTheme.green) + } + + private var currentPeriodLabel: String { + switch span { + case .day: return "오늘" + case .week: return "이번 주" + case .month: return "이번 달" + } + } + + private var periodLabel: String { + let keys = math.dayKeys(in: spanRange) + switch span { + case .day: + return Format.fullDate(anchorDayKey) + case .week: + guard let first = keys.first, let last = keys.last else { return "" } + return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))" + case .month: + return anchorDayKey.formatted(.dateTime.year().month()) + } + } + + private func movePeriod(_ delta: Int) { + let cal = math.calendar + switch span { + case .day: + anchorDayKey = cal.date(byAdding: .day, value: delta, to: anchorDayKey)! + case .week: + anchorDayKey = cal.date(byAdding: .day, value: 7 * delta, to: anchorDayKey)! + case .month: + anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)! + } + } + + // MARK: 필터 (꼬리표·행동 다중 선택, 기본 전체 선택) + + private var isFiltering: Bool { + !excludedTagIDs.isEmpty || !excludedActionIDs.isEmpty + } + + /// 행동이 통계에 포함되는지: 직접 해제되지 않았고, 선택된 꼬리표가 하나라도 있어야 함 + private func passesFilter(_ action: Action) -> Bool { + guard !excludedActionIDs.contains(action.persistentModelID) else { return false } + if action.tags.isEmpty { return true } + return action.tags.contains { !excludedTagIDs.contains($0.persistentModelID) } + } + + private var filteredActions: [Action] { + allActions.filter(passesFilter) + } + + private var filterChip: some View { + HStack(spacing: 6) { + Image(systemName: "line.3.horizontal.decrease") + .font(.caption2.weight(.semibold)) + Text("행동 \(filteredActions.count)/\(allActions.count)개 표시 중") + .font(.caption.weight(.semibold)) + .lineLimit(1) + Button { + withAnimation { + excludedTagIDs = [] + 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 func segments(in range: Range) -> [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..) -> [CountItem] { + entries + .filter { range.contains($0.timestamp) } + .compactMap { entry in + entry.action.map { CountItem(entry: entry, action: $0) } + } + .filter { passesFilter($0.action) } + } + + /// 평균 계산용: 기간 내 "이미 시작된" 날 수 (미래 날짜로 평균이 희석되지 않게) + private var elapsedDayCount: Int { + max( + math.dayKeys(in: spanRange) + .filter { math.dayRange(forKey: $0).lowerBound <= .now } + .count, + 1 + ) + } + + // MARK: 하루 — 꼬리표별 가로 막대 (비율 비교) + + @ViewBuilder + private var dayCharts: some View { + let stats = tagStats(in: spanRange) + tagTimeBarCard(stats) + tagCountBarCard(stats) + } + + private func tagStats(in range: Range) -> [TagStat] { + var seconds: [String: TimeInterval] = [:] + var countsByTag: [String: Int] = [:] + var colors: [String: Color] = [:] + + func tagNames(for action: Action) -> [(String, Color)] { + if action.tags.isEmpty { return [("꼬리표 없음", Color.gray)] } + return action.sortedTags.map { ($0.name, $0.color) } + } + + for item in segments(in: range) { + for (name, color) in tagNames(for: item.action) { + seconds[name, default: 0] += item.duration + colors[name] = color + } + } + for item in counts(in: range) { + for (name, color) in tagNames(for: item.action) { + countsByTag[name, default: 0] += item.entry.amount + colors[name] = color + } + } + + let names = Set(seconds.keys).union(countsByTag.keys) + return names + .map { name in + TagStat( + name: name, + color: colors[name] ?? .gray, + seconds: seconds[name] ?? 0, + count: countsByTag[name] ?? 0 + ) + } + .sorted { $0.seconds > $1.seconds } + } + + @ViewBuilder + private func tagTimeBarCard(_ stats: [TagStat]) -> some View { + let timeStats = stats.filter { $0.seconds > 0 } + chartCard("꼬리표별 시간 비교") { + if timeStats.isEmpty { + emptyChartText + } else { + Chart(timeStats) { stat in + BarMark( + x: .value("시간", stat.seconds / 3600), + y: .value("꼬리표", stat.name) + ) + .foregroundStyle(stat.color) + .cornerRadius(4) + .annotation(position: .trailing) { + Text(Format.durationShort(stat.seconds)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartXAxisLabel("시간(h)") + .frame(height: CGFloat(timeStats.count) * 44 + 30) + } + } + } + + @ViewBuilder + private func tagCountBarCard(_ stats: [TagStat]) -> some View { + let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count } + chartCard("꼬리표별 횟수 비교") { + if countStats.isEmpty { + emptyChartText + } else { + Chart(countStats) { stat in + BarMark( + x: .value("횟수", stat.count), + y: .value("꼬리표", stat.name) + ) + .foregroundStyle(stat.color) + .cornerRadius(4) + .annotation(position: .trailing) { + Text("\(stat.count)회") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartXAxisLabel("횟수") + .frame(height: CGFloat(countStats.count) * 44 + 30) + } + } + } + + // MARK: 주간 — 행동별 일별 꺾은선 + 주간 합계 막대 + + @ViewBuilder + private var weekCharts: some View { + dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true) + dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true) + totalsBarCard("주간 시간 합계", type: .time) + totalsBarCard("주간 횟수 합계", type: .count) + } + + // MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + + @ViewBuilder + private var monthCharts: some View { + weeklyLineCard("행동별 시간 (주차별)", type: .time) + weeklyLineCard("행동별 횟수 (주차별)", type: .count) + dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false) + dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false) + totalsBarCard("월간 시간 합계", type: .time) + totalsBarCard("월간 횟수 합계", type: .count) + } + + // MARK: 행동별 시리즈 계산 + + private struct ActionDayPoint: Identifiable { + let dayKey: Date + let actionName: String + let value: Double + + var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(actionName)" } + } + + private struct ActionWeekPoint: Identifiable { + let weekLabel: String + let actionName: String + let value: Double + + var id: String { "\(weekLabel)-\(actionName)" } + } + + private struct ActionTotal: Identifiable { + let name: String + let color: Color + let value: Double + + var id: String { name } + } + + /// 기간 내 값이 있는(0이 아닌) 필터 통과 행동들 — 시리즈/범례 대상 + private func activeActions(for type: TrackingType) -> [Action] { + let agg = Aggregator(math: math) + return filteredActions + .filter { $0.trackingType == type } + .filter { action in + switch type { + case .time: return agg.seconds(for: action, in: spanRange) > 0 + case .count: return agg.count(for: action, in: spanRange) > 0 + } + } + } + + private func dailyPoints(for actions: [Action], type: TrackingType) -> [ActionDayPoint] { + let agg = Aggregator(math: math) + var result: [ActionDayPoint] = [] + for key in math.dayKeys(in: spanRange) { + let range = math.dayRange(forKey: key) + for action in actions { + let value: Double = switch type { + case .time: agg.seconds(for: action, in: range) / 3600 + case .count: Double(agg.count(for: action, in: range)) + } + result.append(ActionDayPoint(dayKey: key, actionName: action.name, value: value)) + } + } + return result + } + + /// 한 달을 주 단위(주 시작 요일 설정 기준, 월 경계에서 잘림)로 쪼갠 범위들 + private var weekRangesInMonth: [Range] { + let range = spanRange + var result: [Range] = [] + var cursor = range.lowerBound + while cursor < range.upperBound { + let week = math.weekRange(containing: cursor) + result.append(max(week.lowerBound, range.lowerBound).. [ActionWeekPoint] { + let agg = Aggregator(math: math) + var result: [ActionWeekPoint] = [] + for (index, weekRange) in weekRangesInMonth.enumerated() { + for action in actions { + let value: Double = switch type { + case .time: agg.seconds(for: action, in: weekRange) / 3600 + case .count: Double(agg.count(for: action, in: weekRange)) + } + result.append(ActionWeekPoint(weekLabel: "\(index + 1)주차", actionName: action.name, value: value)) + } + } + return result + } + + private func totals(for type: TrackingType) -> [ActionTotal] { + let agg = Aggregator(math: math) + return activeActions(for: type) + .map { action in + let value: Double = switch type { + case .time: agg.seconds(for: action, in: spanRange) + case .count: Double(agg.count(for: action, in: spanRange)) + } + return ActionTotal(name: action.name, color: action.color, value: value) + } + .sorted { $0.value > $1.value } + } + + private func valueLabel(_ value: Double, type: TrackingType) -> String { + switch type { + case .time: return Format.durationShort(value) + case .count: return "\(Int(value.rounded()))회" + } + } + + // MARK: 꺾은선 카드 (선 색 = 행동/태그 색, 범례 표시) + + @ViewBuilder + private func dailyLineCard(_ title: String, type: TrackingType, weekdayAxis: Bool) -> some View { + let actions = activeActions(for: type) + let names = actions.map(\.name) + let colors = actions.map(\.color) + chartCard(title) { + if actions.isEmpty { + emptyChartText + } else { + Chart(dailyPoints(for: actions, type: type)) { point in + LineMark( + x: .value("날짜", point.dayKey, unit: .day), + y: .value(type == .time ? "시간" : "횟수", point.value) + ) + .foregroundStyle(by: .value("행동", point.actionName)) + .symbol(by: .value("행동", point.actionName)) + .interpolationMethod(.monotone) + } + .chartForegroundStyleScale(domain: names, range: colors) + .chartXAxis { + if weekdayAxis { + AxisMarks(values: .stride(by: .day)) { value in + AxisGridLine() + AxisValueLabel { + if let date = value.as(Date.self) { + Text(Format.weekdayShort(math.calendar.component(.weekday, from: date))) + } + } + } + } else { + AxisMarks { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.day()) + } + } + } + .chartYAxisLabel(type == .time ? "시간(h)" : "횟수") + .frame(height: 200) + } + } + } + + @ViewBuilder + private func weeklyLineCard(_ title: String, type: TrackingType) -> some View { + let actions = activeActions(for: type) + let names = actions.map(\.name) + let colors = actions.map(\.color) + chartCard(title) { + if actions.isEmpty { + emptyChartText + } else { + Chart(weeklyPoints(for: actions, type: type)) { point in + LineMark( + x: .value("주차", point.weekLabel), + y: .value(type == .time ? "시간" : "횟수", point.value) + ) + .foregroundStyle(by: .value("행동", point.actionName)) + .symbol(by: .value("행동", point.actionName)) + .interpolationMethod(.monotone) + } + .chartForegroundStyleScale(domain: names, range: colors) + .chartYAxisLabel(type == .time ? "시간(h)" : "횟수") + .frame(height: 200) + } + } + } + + // MARK: 합계 막대 카드 (기간 누적 비율 비교 + 합계·하루 평균) + + @ViewBuilder + private func totalsBarCard(_ title: String, type: TrackingType) -> some View { + let items = totals(for: type) + chartCard(title) { + if items.isEmpty { + emptyChartText + } else { + Chart(items) { item in + BarMark( + x: .value(type == .time ? "시간" : "횟수", type == .time ? item.value / 3600 : item.value), + y: .value("행동", item.name) + ) + .foregroundStyle(item.color) + .cornerRadius(4) + .annotation(position: .trailing) { + Text(valueLabel(item.value, type: type)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartXAxisLabel(type == .time ? "시간(h)" : "횟수") + .frame(height: CGFloat(items.count) * 40 + 30) + + let total = items.reduce(0.0) { $0 + $1.value } + let average = total / Double(elapsedDayCount) + HStack(spacing: 0) { + summaryCell(label: "\(span.label) 합계", value: valueLabel(total, type: type)) + Divider().frame(height: 28) + summaryCell( + label: "하루 평균", + value: type == .time + ? Format.durationShort(average) + : String(format: "%.1f회", average) + ) + } + .padding(.top, 4) + } + } + } + + private func summaryCell(label: String, value: String) -> some View { + VStack(spacing: 2) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.subheadline.weight(.semibold).monospacedDigit()) + .foregroundStyle(AppTheme.green) + } + .frame(maxWidth: .infinity) + } + + // MARK: 공통 + + private func chartCard(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.subheadline.weight(.semibold)) + content() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private var emptyChartText: some View { + Text("표시할 기록이 없어요") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } +} + +// MARK: - 태그별 집계 항목 + +struct TagStat: Identifiable { + let name: String + let color: Color + let seconds: TimeInterval + let count: Int + + var id: String { name } +} + +// MARK: - 통계 필터 시트 (꼬리표·행동 다중 선택, 기본 전체 선택) + +struct StatsFilterSheet: View { + @Environment(\.dismiss) private var dismiss + let tags: [Tag] + let actions: [Action] + @Binding var excludedTagIDs: Set + @Binding var excludedActionIDs: Set + + var body: some View { + NavigationStack { + List { + Section { + selectableRow( + title: "전체 선택", + symbol: "checkmark.circle", + color: AppTheme.green, + selected: excludedTagIDs.isEmpty && excludedActionIDs.isEmpty + ) { + excludedTagIDs = [] + excludedActionIDs = [] + } + } + Section { + ForEach(tags) { tag in + selectableRow( + title: tag.name, + symbol: "tag.fill", + color: tag.color, + selected: !excludedTagIDs.contains(tag.persistentModelID) + ) { + toggle(tag.persistentModelID, in: &excludedTagIDs) + } + } + } header: { + Text("꼬리표") + } footer: { + Text("해제한 꼬리표에만 속한 행동은 통계에서 제외돼요.") + } + Section { + ForEach(actions) { action in + selectableRow( + title: action.name, + symbol: action.symbolName, + color: action.color, + selected: !excludedActionIDs.contains(action.persistentModelID) + ) { + toggle(action.persistentModelID, in: &excludedActionIDs) + } + } + } header: { + Text("행동") + } footer: { + Text("선택한 꼬리표·행동의 기록만 통계에 반영돼요.") + } + } + .scrollContentBackground(.hidden) + .background(AppTheme.background) + .navigationTitle("통계 필터") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("완료") { dismiss() } + } + } + } + } + + private func toggle(_ id: PersistentIdentifier, in set: inout Set) { + if set.contains(id) { + set.remove(id) + } else { + set.insert(id) + } + } + + private func selectableRow( + title: String, + symbol: String, + color: Color, + selected: Bool, + onTap: @escaping () -> Void + ) -> some View { + Button(action: onTap) { + HStack(spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text(title) + .foregroundStyle(.primary) + Spacer() + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? AppTheme.green : Color.secondary.opacity(0.4)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +}