feat: extract stats tab and enhance progress tracking UI
[Main Tab & Routing] - feat(main): add options for goal widget to show aggregate or individual (up to 3) resolutions - fix(main): route long-press 'view records' directly to the filtered Records tab [Statistics & Charts] - feat(stats): separate Statistics into an independent main tab and remove from Records - feat(stats): add multi-select filters for tags and actions (default: all) - feat(stats): add daily horizontal bar charts for tag ratio comparison - feat(stats): add weekly line charts by day and cumulative bar charts - feat(stats): add monthly line charts (by day and week) and cumulative bar charts [Core Logic] - feat(goals): update logic for 'maintain below' targets to show 100% if met, 0% if exceeded
This commit is contained in:
parent
afb7435656
commit
5475590a47
Binary file not shown.
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 시점 기준 달성 여부
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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개" : {
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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<Date>) -> [SessionSegmentItem]
|
||||
let counts: (Range<Date>) -> [CountItem]
|
||||
let math: DayMath
|
||||
|
||||
private var spanRange: Range<Date> {
|
||||
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<Date>) -> [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<String>()
|
||||
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)
|
||||
|
||||
@ -16,8 +16,11 @@ struct MainView: View {
|
||||
private var runningSessions: [TimeSession]
|
||||
@Query(filter: #Predicate<Goal> { $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: - 측정 종료 메모 시트
|
||||
|
||||
@ -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) {
|
||||
|
||||
737
myApp/Haru_Danim/IOS/Views/StatsTabView.swift
Normal file
737
myApp/Haru_Danim/IOS/Views/StatsTabView.swift
Normal file
@ -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<PersistentIdentifier> = []
|
||||
@State private var excludedActionIDs: Set<PersistentIdentifier> = []
|
||||
|
||||
private var math: DayMath { DayMath() }
|
||||
|
||||
private var spanRange: Range<Date> {
|
||||
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<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
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
/// 평균 계산용: 기간 내 "이미 시작된" 날 수 (미래 날짜로 평균이 희석되지 않게)
|
||||
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<Date>) -> [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<Date>] {
|
||||
let range = spanRange
|
||||
var result: [Range<Date>] = []
|
||||
var cursor = range.lowerBound
|
||||
while cursor < range.upperBound {
|
||||
let week = math.weekRange(containing: cursor)
|
||||
result.append(max(week.lowerBound, range.lowerBound)..<min(week.upperBound, range.upperBound))
|
||||
cursor = week.upperBound
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func weeklyPoints(for actions: [Action], type: TrackingType) -> [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<PersistentIdentifier>
|
||||
@Binding var excludedActionIDs: Set<PersistentIdentifier>
|
||||
|
||||
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<PersistentIdentifier>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user