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:
songyc macbook 2026-07-09 04:36:14 +09:00
parent afb7435656
commit 5475590a47
11 changed files with 987 additions and 418 deletions

View File

@ -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
}

View File

@ -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

View File

@ -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
}
}
///

View File

@ -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 {

View File

@ -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개" : {

View File

@ -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)
}

View File

@ -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)

View File

@ -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: -

View File

@ -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) {

View 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)
}
}