From afb7435656992e1ed3b121bd1eb511559e34aec6 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Thu, 9 Jul 2026 02:22:04 +0900 Subject: [PATCH] feat: enhance records with charts/filters and update main tab UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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 --- .../UserInterfaceState.xcuserstate | Bin 21210 -> 21209 bytes myApp/Haru_Danim/IOS/ContentView.swift | 2 +- myApp/Haru_Danim/IOS/Core/DebugSeed.swift | 1 + myApp/Haru_Danim/IOS/Core/Models.swift | 2 + myApp/Haru_Danim/IOS/Localizable.xcstrings | 45 +++ myApp/Haru_Danim/IOS/Views/GoalViews.swift | 2 + myApp/Haru_Danim/IOS/Views/HistoryView.swift | 336 +++++++++++++++++- myApp/Haru_Danim/IOS/Views/MainView.swift | 91 ++++- .../Haru_Danim/IOS/Views/RecordEditors.swift | 9 + myApp/Haru_Danim/IOS/Views/SettingsView.swift | 25 ++ 10 files changed, 507 insertions(+), 6 deletions(-) diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 7fd135648761c935a94fae8ed75edf210fff656d..9bfee6461234d12bc0417acd0b5b16f16c5c68e1 100644 GIT binary patch delta 2892 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 F|1W{~sWbop delta 2891 zcmZXQdvpwk0>*ceO(G%>qLIwZ&dg?JXXm*)nVt8}?xGX#AoVPbB7_i$l6YOZ8>+bC zxusgAh}2EBszQ6E-mZGL>RGNwO1!B@we`+*?zw;6{`33eJKs6q`F&?=fO9p##%drg zEhZzTP2E2L2uMh_r`R*?aNVJp(eZKMZ&f!>)ftIvlVh8wfpx!R36{M_is{PeMb?nI45wS&;KvE^pyb?&Pwzme(*fS~tF|x1{@I{E@_AFbf z9Rx1D)xDReqM~@%(8=Y)$M;D4i&|v_?IJ!JZa;jgG@dC z<(@v1Kf4rNVOp)pcrjj%SK`%p%|=F2D}gD@r!Q=Cdi7*VePHI5*J_&08fIa!7Jb$@K^9Y_z-*qz5tsb0E&g; zpadul0wEU^fVx1vpgvGP=uK!KGz=<&Mna>Y5~viaf@+~7&{gPR7>1kPJ)x+ zRJbLa4ihj5Q!oQ_um=`k5td*D9tM8^?}qO?AV*inG{+Z?t&VMu9gf|Oy^ej3vyRJ- z>yDd_+m5@ArwD+=B5_Cpl7gfmt&rA820|b?h=t@K1CSv|Au=2pfs`U+k#R^lG64yP zkt$>!G9OupEJi*>)*x$xgy1@~|FQ zK30Ow!Iom%v8&jh&SWR!3_ANbXFET3e(kJx?se{S?spz^9(Epeo^zgeUUW8O`?A|+ zx6ih+J7h1*uE}1PU7NigkH^z-7)Njn4`<^9&f{KO#ARH=4SYEMDSiTPz^~%h@tgRs z_H>G_1x7ICS%ArGJ#AalgLccL1H9M zQY1@yNP)~F$CA^?>Euju7Fk6uAQzEQat&ESZX&mm+sK{d9`Yc0g#3a0i9A6*cRSoY z-2L6d-ILr?+%wz@-PP_I_a=9pd$ao+_g?n__aXOD_i^`0_ZjzD_YL>&R6LbPwV=YO zR4eK=DwBdJgz{1%B~u#Zr-D>F%Az__ovA{qjG9iZpw?46sAJSc>N<6Ux=lTz0lFET zLbstaX^8gI7M)MOLHDBj&_n6r^hkO%T}qFor_*!kDtZCEj9x*1M<1XM(ue3H^jW%* zzDD1qZ_{__dtv%1{erPEu}nOZ$RshD49LI?%Dlt8%Pe6&WIke+Gb@-+nbk}+Q^Txd zYMJ%S9%di2pE<}JW`1CfF(;T)%o*k!bDp`#TxV`EcbR+4V-{dz**G?VO=OeUOxD3- zEY4Ca%X(OW&11*1)7a_kOm-F|^#R7t5t_IOpO-F2uFxa=A`iSFSsk&kf>+aD`kEHi z7kQO8_yFIYxA+cxC%z9qlrQ7Q@fG|eehNR8pTjQ=^UL`a{3^bNU&q(->-l^bW>=Q;1W>bc>$?YZlD z>22ms@}_uOdRu!(c*lCDd8d14dS`p*c^7)4-X-1@-nHH@y&JuC-Ywo;-qYSo-Xm?KmP3xvf&t?-4gLD(p461E863iZM^ zVTZ6=*emQ4F6RI_uAGiJqjMJJY|ClLX%Z39FIr+pv5VME>>~~m2a7|+VPcUuQmhcC zh||P~I8&T0&K2j0pNGYx;yLk>cv)-|Z;E%sU&Z@kldpv@-Iw9Z^1(jbNBU?V>*IZW zeY1VFzD>TJzT>`=z6-uvz8AhGsfCm*rATR#L&}y2$t`h`N6L|WQhTX~G*~K>ilkzx zL@JZUNfpv0X^vDSEsz#VOQdDea%rWsS_)T7Ur1j{8>OvMy|i7rAl;JgNe`soq{q@T z>7{IwlVw6CWlCmbPWH%xEXtCs$eL`(`EqZ$uiRfAAQ#AQ%R}X1a*SGPi>{^1BsDYY8!!%qYHLqrB z?KDg4sCCnNXm4n}wE}IhR;Ufv{-({==4$h`McN12QmtBBtJP@hw9VSLTD`Vi+okQ% zj%khB-LQ6Fd#F9q0o|_0>xp^`Jw(hb-k4Bik7-Dqp%8r_Y4#sFi4QDT%C7Fw2G`^tHdg^rdnaE(t6LDWzDhXTJx+;)|p%&m(7)O1Gyb?JLi_ = [] private var math: DayMath { DayMath() } private var dayRange: Range { 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 + + 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) + } +} diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift index 201daa6..6a946e4 100644 --- a/myApp/Haru_Danim/IOS/Views/MainView.swift +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -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 { $0.endAt == nil }, sort: \TimeSession.startAt) private var runningSessions: [TimeSession] + @Query(filter: #Predicate { $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 { diff --git a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift index bf8030f..cded609 100644 --- a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift +++ b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift @@ -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 } + } } } } diff --git a/myApp/Haru_Danim/IOS/Views/SettingsView.swift b/myApp/Haru_Danim/IOS/Views/SettingsView.swift index 0206637..76f147b 100644 --- a/myApp/Haru_Danim/IOS/Views/SettingsView.swift +++ b/myApp/Haru_Danim/IOS/Views/SettingsView.swift @@ -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 { + 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