[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
669 lines
24 KiB
Swift
669 lines
24 KiB
Swift
//
|
|
// HistoryView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 기록 탭: 일간 목록 / 타임테이블(하루·일주일) (CLAUDE.md §6.5 — 통계는 통계 탭으로 분리)
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
enum HistoryMode: String, CaseIterable, Identifiable {
|
|
case list, timetable
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .list: return "목록"
|
|
case .timetable: return "타임테이블"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 표시용 아이템
|
|
|
|
struct SessionSegmentItem: Identifiable {
|
|
let session: TimeSession
|
|
let action: Action
|
|
let range: Range<Date>
|
|
|
|
var id: String {
|
|
"\(String(describing: session.persistentModelID))-\(range.lowerBound.timeIntervalSinceReferenceDate)"
|
|
}
|
|
|
|
var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) }
|
|
}
|
|
|
|
struct CountItem: Identifiable {
|
|
let entry: CountEntry
|
|
let action: Action
|
|
|
|
var id: String { String(describing: entry.persistentModelID) }
|
|
}
|
|
|
|
// MARK: - 기록 탭 본체
|
|
|
|
struct HistoryView: View {
|
|
@Environment(AppRouter.self) private var router
|
|
|
|
@Query private var sessions: [TimeSession]
|
|
@Query private var entries: [CountEntry]
|
|
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
|
@Query(sort: \Action.sortOrder) private var allActions: [Action]
|
|
|
|
@State private var selectedDayKey: Date = DayMath().dayKey(for: .now)
|
|
@State private var mode: HistoryMode = {
|
|
#if DEBUG
|
|
if let raw = UserDefaults.standard.string(forKey: "historyMode"),
|
|
let mode = HistoryMode(rawValue: raw) {
|
|
return mode
|
|
}
|
|
#endif
|
|
return .list
|
|
}()
|
|
@State private var timetableWeekly = false
|
|
@State private var editingSession: TimeSession?
|
|
@State private var editingEntry: CountEntry?
|
|
@State private var showingCalendar = false
|
|
@State private var showingFilter = false
|
|
@State private var filterTag: Tag?
|
|
@State private var filterActionIDs: Set<PersistentIdentifier> = []
|
|
|
|
private var math: DayMath { DayMath() }
|
|
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
dateHeader
|
|
Picker("보기", selection: $mode) {
|
|
ForEach(HistoryMode.allCases) { m in
|
|
Text(m.label).tag(m)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 8)
|
|
|
|
if isFiltering {
|
|
filterChip
|
|
}
|
|
|
|
switch mode {
|
|
case .list:
|
|
listView
|
|
case .timetable:
|
|
TimetableView(
|
|
weekly: $timetableWeekly,
|
|
selectedDayKey: selectedDayKey,
|
|
segments: segments(in:),
|
|
counts: counts(in:),
|
|
math: math,
|
|
onTapSession: { editingSession = $0 },
|
|
onTapEntry: { editingEntry = $0 }
|
|
)
|
|
}
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationTitle("기록")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showingFilter = true
|
|
} label: {
|
|
Image(systemName: isFiltering
|
|
? "line.3.horizontal.decrease.circle.fill"
|
|
: "line.3.horizontal.decrease.circle")
|
|
}
|
|
}
|
|
}
|
|
.sheet(item: $editingSession) { session in
|
|
SessionEditorView(session: session)
|
|
}
|
|
.sheet(item: $editingEntry) { entry in
|
|
CountEntryEditorView(entry: entry)
|
|
}
|
|
.sheet(isPresented: $showingFilter) {
|
|
HistoryFilterSheet(
|
|
tags: tags,
|
|
actions: allActions,
|
|
filterTag: $filterTag,
|
|
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개 또는 행동 여러 개)
|
|
|
|
private var isFiltering: Bool {
|
|
filterTag != nil || !filterActionIDs.isEmpty
|
|
}
|
|
|
|
private func passesFilter(_ action: Action) -> Bool {
|
|
if !filterActionIDs.isEmpty {
|
|
return filterActionIDs.contains(action.persistentModelID)
|
|
}
|
|
if let filterTag {
|
|
return action.tags.contains(filterTag)
|
|
}
|
|
return true
|
|
}
|
|
|
|
private var filterLabel: String {
|
|
if !filterActionIDs.isEmpty {
|
|
let names = allActions
|
|
.filter { filterActionIDs.contains($0.persistentModelID) }
|
|
.map(\.name)
|
|
return names.count <= 2
|
|
? names.joined(separator: ", ")
|
|
: "\(names.prefix(2).joined(separator: ", ")) 외 \(names.count - 2)개"
|
|
}
|
|
if let filterTag {
|
|
return "#\(filterTag.name)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
private var filterChip: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "line.3.horizontal.decrease")
|
|
.font(.caption2.weight(.semibold))
|
|
Text(filterLabel)
|
|
.font(.caption.weight(.semibold))
|
|
.lineLimit(1)
|
|
Button {
|
|
withAnimation {
|
|
filterTag = nil
|
|
filterActionIDs = []
|
|
}
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.foregroundStyle(AppTheme.green)
|
|
.padding(.leading, 10)
|
|
.padding(.trailing, 2)
|
|
.padding(.vertical, 4)
|
|
.background(AppTheme.green.opacity(0.12), in: Capsule())
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 6)
|
|
}
|
|
|
|
// MARK: 날짜 이동 헤더 (날짜를 누르면 달력 팝업)
|
|
|
|
private var dateHeader: some View {
|
|
HStack {
|
|
Button {
|
|
moveDay(-1)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.frame(width: 44, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
Spacer()
|
|
Button {
|
|
showingCalendar = true
|
|
} label: {
|
|
VStack(spacing: 1) {
|
|
HStack(spacing: 4) {
|
|
Text(Format.fullDate(selectedDayKey))
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
Image(systemName: "chevron.down.circle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
if math.dayKey(for: .now) == selectedDayKey {
|
|
Text("오늘")
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.popover(isPresented: $showingCalendar) {
|
|
DatePicker(
|
|
"날짜 선택",
|
|
selection: calendarSelection,
|
|
displayedComponents: .date
|
|
)
|
|
.datePickerStyle(.graphical)
|
|
.frame(minWidth: 320, minHeight: 340)
|
|
.padding(8)
|
|
.presentationCompactAdaptation(.popover)
|
|
}
|
|
Spacer()
|
|
Button {
|
|
moveDay(1)
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.frame(width: 44, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
Button("오늘") {
|
|
selectedDayKey = math.dayKey(for: .now)
|
|
}
|
|
.font(.caption)
|
|
.buttonStyle(.bordered)
|
|
.buttonBorderShape(.capsule)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 10)
|
|
.tint(AppTheme.green)
|
|
}
|
|
|
|
/// 달력 팝업 선택값 ↔ 논리적 하루 키 변환 (하루 시작 시간 설정과 무관하게 정오 기준으로 매핑)
|
|
private var calendarSelection: Binding<Date> {
|
|
Binding {
|
|
selectedDayKey
|
|
} set: { picked in
|
|
let cal = math.calendar
|
|
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: picked) ?? picked
|
|
selectedDayKey = math.dayKey(for: noon)
|
|
showingCalendar = false
|
|
}
|
|
}
|
|
|
|
private func moveDay(_ delta: Int) {
|
|
selectedDayKey = math.calendar.date(byAdding: .day, value: delta, to: selectedDayKey)!
|
|
}
|
|
|
|
// MARK: 데이터 수집 (구간 겹침으로 하루 경계 분할 자동 반영)
|
|
|
|
private func segments(in range: Range<Date>) -> [SessionSegmentItem] {
|
|
let now = Date.now
|
|
var items: [SessionSegmentItem] = []
|
|
for session in sessions {
|
|
guard let action = session.action, passesFilter(action) else { continue }
|
|
let end = session.endAt ?? now
|
|
let lower = max(session.startAt, range.lowerBound)
|
|
let upper = min(end, range.upperBound)
|
|
guard lower < upper else { continue }
|
|
items.append(SessionSegmentItem(session: session, action: action, range: lower..<upper))
|
|
}
|
|
return items.sorted { $0.range.lowerBound < $1.range.lowerBound }
|
|
}
|
|
|
|
private func counts(in range: Range<Date>) -> [CountItem] {
|
|
entries
|
|
.filter { range.contains($0.timestamp) }
|
|
.compactMap { entry in
|
|
entry.action.map { CountItem(entry: entry, action: $0) }
|
|
}
|
|
.filter { passesFilter($0.action) }
|
|
.sorted { $0.entry.timestamp < $1.entry.timestamp }
|
|
}
|
|
|
|
// MARK: 일간 목록 뷰
|
|
|
|
private var listView: some View {
|
|
let daySegments = segments(in: dayRange)
|
|
let dayCounts = counts(in: dayRange)
|
|
return List {
|
|
Section("시간 기록") {
|
|
if daySegments.isEmpty {
|
|
Text("시간 기록이 없어요").foregroundStyle(.secondary)
|
|
}
|
|
ForEach(daySegments) { item in
|
|
Button {
|
|
editingSession = item.session
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: item.action.symbolName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 30, height: 30)
|
|
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(item.action.name)
|
|
.font(.subheadline.weight(.medium))
|
|
Text("\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? "진행 중" : Format.time(item.range.upperBound))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if !item.session.note.isEmpty {
|
|
Label(item.session.note, systemImage: "note.text")
|
|
.font(.caption)
|
|
.foregroundStyle(AppTheme.yellow)
|
|
.lineLimit(2)
|
|
.padding(.top, 1)
|
|
}
|
|
}
|
|
Spacer()
|
|
Text(Format.durationShort(item.duration))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
Section("횟수 기록") {
|
|
if dayCounts.isEmpty {
|
|
Text("횟수 기록이 없어요").foregroundStyle(.secondary)
|
|
}
|
|
ForEach(dayCounts) { item in
|
|
Button {
|
|
editingEntry = item.entry
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: item.action.symbolName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 30, height: 30)
|
|
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(item.action.name)
|
|
.font(.subheadline.weight(.medium))
|
|
Text(Format.time(item.entry.timestamp))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if !item.entry.note.isEmpty {
|
|
Label(item.entry.note, systemImage: "note.text")
|
|
.font(.caption)
|
|
.foregroundStyle(AppTheme.yellow)
|
|
.lineLimit(2)
|
|
.padding(.top, 1)
|
|
}
|
|
}
|
|
Spacer()
|
|
Text("+\(item.entry.amount)")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
}
|
|
|
|
// MARK: - 타임테이블 뷰
|
|
|
|
struct TimetableView: View {
|
|
@Binding var weekly: Bool
|
|
let selectedDayKey: Date
|
|
let segments: (Range<Date>) -> [SessionSegmentItem]
|
|
let counts: (Range<Date>) -> [CountItem]
|
|
let math: DayMath
|
|
let onTapSession: (TimeSession) -> Void
|
|
let onTapEntry: (CountEntry) -> Void
|
|
|
|
private var hourHeight: CGFloat { weekly ? 26 : 48 }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Picker("범위", selection: $weekly) {
|
|
Text("하루").tag(false)
|
|
Text("일주일").tag(true)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal)
|
|
|
|
ScrollView {
|
|
HStack(alignment: .top, spacing: 4) {
|
|
hourLabels
|
|
if weekly {
|
|
weekColumns
|
|
} else {
|
|
dayColumn(for: selectedDayKey)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 20)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var startHour: Int {
|
|
let start = math.dayRange(forKey: selectedDayKey).lowerBound
|
|
return math.calendar.component(.hour, from: start)
|
|
}
|
|
|
|
private var hourLabels: some View {
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
ForEach(0..<24, id: \.self) { i in
|
|
Text(String(format: "%02d", (startHour + i) % 24))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.frame(height: hourHeight, alignment: .top)
|
|
}
|
|
}
|
|
.frame(width: 26)
|
|
}
|
|
|
|
private var weekColumns: some View {
|
|
let weekRange = math.weekRange(containing: math.dayRange(forKey: selectedDayKey).lowerBound)
|
|
let keys = math.dayKeys(in: weekRange)
|
|
return HStack(alignment: .top, spacing: 3) {
|
|
ForEach(keys, id: \.self) { key in
|
|
VStack(spacing: 2) {
|
|
Text(Format.weekdayShort(math.calendar.component(.weekday, from: key)))
|
|
.font(.caption2)
|
|
.foregroundStyle(key == selectedDayKey ? AppTheme.green : .secondary)
|
|
dayColumn(for: key)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dayColumn(for key: Date) -> some View {
|
|
let range = math.dayRange(forKey: key)
|
|
let daySegments = segments(range)
|
|
let dayCounts = counts(range)
|
|
return GeometryReader { geo in
|
|
ZStack(alignment: .topLeading) {
|
|
gridLines(width: geo.size.width)
|
|
ForEach(daySegments) { item in
|
|
block(item, in: range, width: geo.size.width)
|
|
}
|
|
ForEach(Array(dayCounts.enumerated()), id: \.element.id) { index, item in
|
|
marker(item, in: range, width: geo.size.width, index: index)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: hourHeight * 24)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
|
|
private func gridLines(width: CGFloat) -> some View {
|
|
VStack(spacing: 0) {
|
|
ForEach(0..<24, id: \.self) { _ in
|
|
Divider().opacity(0.4)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.frame(width: width, height: hourHeight * 24)
|
|
}
|
|
|
|
private func yOffset(for date: Date, in range: Range<Date>) -> CGFloat {
|
|
let seconds = date.timeIntervalSince(range.lowerBound)
|
|
return CGFloat(seconds / 3600) * hourHeight
|
|
}
|
|
|
|
private func block(_ item: SessionSegmentItem, in range: Range<Date>, width: CGFloat) -> some View {
|
|
let y = yOffset(for: item.range.lowerBound, in: range)
|
|
let height = max(CGFloat(item.duration / 3600) * hourHeight, 8)
|
|
return Button {
|
|
onTapSession(item.session)
|
|
} label: {
|
|
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
|
.fill(item.action.color.opacity(0.85))
|
|
.overlay(alignment: .topLeading) {
|
|
if !weekly && height >= 18 {
|
|
Label(item.action.name, systemImage: item.action.symbolName)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
.padding(4)
|
|
} else if height >= 14 {
|
|
Image(systemName: item.action.symbolName)
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(2)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: max(width - 4, 8), height: height)
|
|
.offset(x: 2, y: y)
|
|
}
|
|
|
|
private func marker(_ item: CountItem, in range: Range<Date>, width: CGFloat, index: Int) -> some View {
|
|
let y = yOffset(for: item.entry.timestamp, in: range)
|
|
let size: CGFloat = weekly ? 10 : 18
|
|
let x = 4 + CGFloat(index % 5) * (size + 4)
|
|
return Button {
|
|
onTapEntry(item.entry)
|
|
} label: {
|
|
ZStack {
|
|
Circle()
|
|
.fill(item.action.color)
|
|
Circle()
|
|
.strokeBorder(AppTheme.surface, lineWidth: 2)
|
|
if !weekly {
|
|
Image(systemName: item.action.symbolName)
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.offset(x: min(x, width - size), y: max(y - size / 2, 0))
|
|
}
|
|
}
|
|
|
|
// MARK: - 기록 필터 시트 (꼬리표 1개 또는 행동 다중 선택)
|
|
|
|
struct HistoryFilterSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let tags: [Tag]
|
|
let actions: [Action]
|
|
@Binding var filterTag: Tag?
|
|
@Binding var filterActionIDs: Set<PersistentIdentifier>
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
selectableRow(
|
|
title: "전체 보기",
|
|
symbol: "square.grid.2x2",
|
|
color: AppTheme.green,
|
|
selected: filterTag == nil && filterActionIDs.isEmpty
|
|
) {
|
|
filterTag = nil
|
|
filterActionIDs = []
|
|
}
|
|
}
|
|
Section {
|
|
ForEach(tags) { tag in
|
|
selectableRow(
|
|
title: tag.name,
|
|
symbol: "tag.fill",
|
|
color: tag.color,
|
|
selected: filterTag == tag
|
|
) {
|
|
filterTag = tag
|
|
filterActionIDs = []
|
|
}
|
|
}
|
|
} header: {
|
|
Text("꼬리표로 보기")
|
|
} footer: {
|
|
Text("선택한 꼬리표가 붙은 행동의 기록만 표시돼요.")
|
|
}
|
|
Section {
|
|
ForEach(actions) { action in
|
|
selectableRow(
|
|
title: action.name,
|
|
symbol: action.symbolName,
|
|
color: action.color,
|
|
selected: filterActionIDs.contains(action.persistentModelID)
|
|
) {
|
|
filterTag = nil
|
|
if filterActionIDs.contains(action.persistentModelID) {
|
|
filterActionIDs.remove(action.persistentModelID)
|
|
} else {
|
|
filterActionIDs.insert(action.persistentModelID)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("행동으로 보기 (여러 개 선택 가능)")
|
|
} footer: {
|
|
Text("목록과 타임테이블 모두에 필터가 적용돼요.")
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("기록 필터")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("완료") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
if selected {
|
|
Image(systemName: "checkmark")
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|