feat: enhance records with charts/filters and update main tab UI
[New Features] - feat(records): add line charts for weekly/monthly trends and averages - feat(records): implement multi-select filters for specific tags and actions - feat(main): add 'view records' option to the action icon long-press menu - feat(main): display goal progress widget with customizable settings [UI/UX & Bug Fixes] - fix(ui): expand touchable areas globally, especially in record lists - chore(main): rename main tab top title from '하루다님' to '모음' - feat(nav): route to goal detail screen when tapping the goal widget
This commit is contained in:
parent
045f0b6f74
commit
afb7435656
Binary file not shown.
@ -82,7 +82,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .main: return "메인"
|
||||
case .main: return "모음"
|
||||
case .action: return "행동"
|
||||
case .tag: return "꼬리표"
|
||||
case .goal: return "목표"
|
||||
|
||||
@ -96,6 +96,7 @@ 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
|
||||
|
||||
@ -188,6 +188,8 @@ final class Goal {
|
||||
var statusRaw: String = GoalStatus.inProgress.rawValue
|
||||
/// 목표 탭에서 하위 다짐 목록 접힘 여부
|
||||
var isCollapsed: Bool = false
|
||||
/// 모음 탭 상단에 진행 현황을 표시할 목표 (설정에서 선택, 최대 1개)
|
||||
var showsOnMain: Bool = false
|
||||
var createdAt: Date = Date()
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
|
||||
|
||||
@ -119,6 +119,12 @@
|
||||
},
|
||||
"기록 직접 추가" : {
|
||||
|
||||
},
|
||||
"기록 필터" : {
|
||||
|
||||
},
|
||||
"기록 확인" : {
|
||||
|
||||
},
|
||||
"기록이 없어요" : {
|
||||
|
||||
@ -155,6 +161,9 @@
|
||||
},
|
||||
"꼬리표가 없어요" : {
|
||||
|
||||
},
|
||||
"꼬리표로 보기" : {
|
||||
|
||||
},
|
||||
"끝" : {
|
||||
|
||||
@ -170,6 +179,9 @@
|
||||
},
|
||||
"누적 기록" : {
|
||||
|
||||
},
|
||||
"누적 횟수" : {
|
||||
|
||||
},
|
||||
"다이나믹 아일랜드 · 잠금화면" : {
|
||||
|
||||
@ -250,6 +262,15 @@
|
||||
},
|
||||
"몇째 주" : {
|
||||
|
||||
},
|
||||
"모음" : {
|
||||
|
||||
},
|
||||
"모음 탭" : {
|
||||
|
||||
},
|
||||
"목록·타임테이블·통계 모두에 필터가 적용돼요." : {
|
||||
|
||||
},
|
||||
"목표" : {
|
||||
|
||||
@ -265,6 +286,12 @@
|
||||
},
|
||||
"목표 수정" : {
|
||||
|
||||
},
|
||||
"목표 진행 현황" : {
|
||||
|
||||
},
|
||||
"목표 진행 현황 표시" : {
|
||||
|
||||
},
|
||||
"목표 추가" : {
|
||||
|
||||
@ -347,6 +374,12 @@
|
||||
},
|
||||
"선택" : {
|
||||
|
||||
},
|
||||
"선택한 꼬리표가 붙은 행동의 기록만 표시돼요." : {
|
||||
|
||||
},
|
||||
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요." : {
|
||||
|
||||
},
|
||||
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : {
|
||||
|
||||
@ -551,6 +584,15 @@
|
||||
},
|
||||
"특정 시각 지정" : {
|
||||
|
||||
},
|
||||
"평균" : {
|
||||
|
||||
},
|
||||
"평균 %@" : {
|
||||
|
||||
},
|
||||
"표시 안 함" : {
|
||||
|
||||
},
|
||||
"표시할 기록이 없어요" : {
|
||||
|
||||
@ -622,6 +664,9 @@
|
||||
},
|
||||
"행동 탭에서 추적할 행동을 추가해 보세요." : {
|
||||
|
||||
},
|
||||
"행동으로 보기 (여러 개 선택 가능)" : {
|
||||
|
||||
},
|
||||
"현재 진행 중" : {
|
||||
|
||||
|
||||
@ -255,6 +255,8 @@ struct GoalDetailView: View {
|
||||
editingQuest = quest
|
||||
} label: {
|
||||
QuestRow(quest: quest)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions {
|
||||
|
||||
@ -49,6 +49,8 @@ struct CountItem: Identifiable {
|
||||
struct HistoryView: 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 selectedDayKey: Date = DayMath().dayKey(for: .now)
|
||||
@State private var mode: HistoryMode = {
|
||||
@ -61,10 +63,21 @@ struct HistoryView: View {
|
||||
return .list
|
||||
}()
|
||||
@State private var timetableWeekly = false
|
||||
@State private var statSpan: StatSpan = .day
|
||||
@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
|
||||
@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) }
|
||||
@ -81,6 +94,10 @@ struct HistoryView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if isFiltering {
|
||||
filterChip
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .list:
|
||||
listView
|
||||
@ -107,12 +124,93 @@ struct HistoryView: View {
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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: 날짜 이동 헤더 (날짜를 누르면 달력 팝업)
|
||||
@ -123,6 +221,8 @@ struct HistoryView: View {
|
||||
moveDay(-1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 44, height: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
@ -162,6 +262,8 @@ struct HistoryView: View {
|
||||
moveDay(1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.frame(width: 44, height: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
Button("오늘") {
|
||||
selectedDayKey = math.dayKey(for: .now)
|
||||
@ -197,7 +299,7 @@ struct HistoryView: View {
|
||||
let now = Date.now
|
||||
var items: [SessionSegmentItem] = []
|
||||
for session in sessions {
|
||||
guard let action = session.action else { continue }
|
||||
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)
|
||||
@ -213,6 +315,7 @@ struct HistoryView: View {
|
||||
.compactMap { entry in
|
||||
entry.action.map { CountItem(entry: entry, action: $0) }
|
||||
}
|
||||
.filter { passesFilter($0.action) }
|
||||
.sorted { $0.entry.timestamp < $1.entry.timestamp }
|
||||
}
|
||||
|
||||
@ -255,6 +358,8 @@ struct HistoryView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -292,6 +397,8 @@ struct HistoryView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -508,6 +615,11 @@ struct StatsView: View {
|
||||
let stats = tagStats(in: spanRange)
|
||||
timeChartCard(stats)
|
||||
countChartCard(stats)
|
||||
if statSpan != .day {
|
||||
let points = trendPoints()
|
||||
timeTrendCard(points)
|
||||
countTrendCard(points)
|
||||
}
|
||||
weeklyTrendCard
|
||||
}
|
||||
.padding()
|
||||
@ -606,6 +718,122 @@ struct StatsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@ -677,3 +905,107 @@ struct StatsView: View {
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// MainView.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// 메인 탭: 행동 버튼 그리드 + 현재 진행 중 영역 + 배치 편집 (CLAUDE.md §6.1)
|
||||
// 모음 탭: 행동 버튼 그리드 + 현재 진행 중 영역 + 목표 진행 현황 + 배치 편집 (CLAUDE.md §6.1)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@ -14,6 +14,7 @@ struct MainView: View {
|
||||
@Query(sort: \Action.sortOrder) private var actions: [Action]
|
||||
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
|
||||
private var runningSessions: [TimeSession]
|
||||
@Query(filter: #Predicate<Goal> { $0.showsOnMain }) private var pinnedGoals: [Goal]
|
||||
|
||||
@AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3
|
||||
@AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0
|
||||
@ -28,6 +29,7 @@ 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?
|
||||
@ -37,6 +39,9 @@ struct MainView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let goal = pinnedGoals.first, !isEditing {
|
||||
GoalSummaryCard(goal: goal)
|
||||
}
|
||||
if anyRunning && !isEditing {
|
||||
runningArea
|
||||
}
|
||||
@ -52,7 +57,7 @@ struct MainView: View {
|
||||
.padding()
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle(Text("하루다님", comment: "앱 이름"))
|
||||
.navigationTitle("모음")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(isEditing ? "완료" : "배치 편집") {
|
||||
@ -67,7 +72,7 @@ struct MainView: View {
|
||||
ActionEditorView(action: action)
|
||||
}
|
||||
.sheet(item: $recordsAction) { action in
|
||||
ActionRecordsSheet(action: action)
|
||||
ActionRecordsSheet(action: action, startWithAdd: recordsStartWithAdd)
|
||||
}
|
||||
.sheet(item: $memoSession) { session in
|
||||
SessionMemoSheet(session: session)
|
||||
@ -174,6 +179,13 @@ struct MainView: View {
|
||||
base
|
||||
.contextMenu {
|
||||
Button {
|
||||
recordsStartWithAdd = false
|
||||
recordsAction = action
|
||||
} label: {
|
||||
Label("기록 확인", systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
Button {
|
||||
recordsStartWithAdd = true
|
||||
recordsAction = action
|
||||
} label: {
|
||||
Label(
|
||||
@ -262,6 +274,79 @@ struct MainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 목표 진행 현황 카드 (설정에서 선택한 목표, 탭하면 목표 상세로 이동)
|
||||
|
||||
struct GoalSummaryCard: View {
|
||||
let goal: Goal
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
GoalDetailView(goal: goal)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: goal.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("목표 진행 현황")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(goal.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if goal.status == .inProgress, let progress = goal.dateProgress() {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ProgressView(value: progress)
|
||||
.tint(goal.color)
|
||||
Text("기간 진행률 \(Format.percent(progress))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
ForEach(goal.sortedQuests.prefix(3)) { quest in
|
||||
questLine(quest)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
.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) {
|
||||
Text(quest.targetName)
|
||||
.font(.caption)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 측정 종료 메모 시트
|
||||
|
||||
struct SessionMemoSheet: View {
|
||||
|
||||
@ -14,6 +14,8 @@ struct ActionRecordsSheet: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let action: Action
|
||||
/// true면 열리자마자 기록 추가 폼을 띄움 (롱프레스 '수동 입력' 메뉴용)
|
||||
var startWithAdd = false
|
||||
|
||||
@State private var editingSession: TimeSession?
|
||||
@State private var editingEntry: CountEntry?
|
||||
@ -51,6 +53,8 @@ struct ActionRecordsSheet: View {
|
||||
editingSession = session
|
||||
} label: {
|
||||
SessionRowLabel(session: session)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions {
|
||||
@ -69,6 +73,8 @@ struct ActionRecordsSheet: View {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
CountRowLabel(entry: entry)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions {
|
||||
@ -100,6 +106,9 @@ struct ActionRecordsSheet: View {
|
||||
CountAddView(action: action)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if startWithAdd { showingAdd = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@Query(sort: \Goal.createdAt) private var goals: [Goal]
|
||||
@AppStorage(SettingsKeys.theme) private var theme = "light"
|
||||
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
|
||||
@AppStorage(SettingsKeys.weekStartWeekday) private var weekStartWeekday = 2
|
||||
@ -33,6 +35,17 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 모음 탭에 표시할 목표 선택 ↔ Goal.showsOnMain 플래그 변환 (최대 1개 유지)
|
||||
private var pinnedGoalSelection: Binding<PersistentIdentifier?> {
|
||||
Binding {
|
||||
goals.first { $0.showsOnMain }?.persistentModelID
|
||||
} set: { newValue in
|
||||
for goal in goals {
|
||||
goal.showsOnMain = goal.persistentModelID == newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("화면") {
|
||||
@ -55,6 +68,18 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
}
|
||||
Section {
|
||||
Picker("목표 진행 현황 표시", selection: pinnedGoalSelection) {
|
||||
Text("표시 안 함").tag(nil as PersistentIdentifier?)
|
||||
ForEach(goals) { goal in
|
||||
Text(goal.title).tag(Optional(goal.persistentModelID))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("모음 탭")
|
||||
} footer: {
|
||||
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요.")
|
||||
}
|
||||
Section {
|
||||
Picker("주 시작 요일", selection: $weekStartWeekday) {
|
||||
ForEach(1...7, id: \.self) { weekday in
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user