feat(filter): revamp filter selection logic and group actions by tags

[Filter UI & Logic]
- feat: implement toggle behavior for the 'Select All' button
- feat: group actions under their respective parent tags in the filter menu
- feat: auto-select all child actions when a parent tag is selected

[Bug Fixes & Calculation]
- fix(records): resolve rendering issue when only partial actions are selected
- fix(stats): calculate tag statistics using only explicitly selected actions instead of all actions
This commit is contained in:
songyc macbook 2026-07-09 19:02:30 +09:00
parent ad9af22e9d
commit 7c85cadd8e
5 changed files with 289 additions and 269 deletions

View File

@ -47,6 +47,16 @@
},
"%lld" : {
},
"%lld/%lld" : {
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld/%2$lld"
}
}
}
},
"%lld개" : {
@ -119,9 +129,6 @@
},
"기록 직접 추가" : {
},
"기록 필터" : {
},
"기록 확인" : {
@ -162,7 +169,7 @@
"꼬리표가 없어요" : {
},
"꼬리표로 보기" : {
"꼬리표를 누르면 소속 행동이 한 번에 선택·해제되고, 그 안에서 행동을 개별로 켜고 끌 수 있어요." : {
},
"끝" : {
@ -265,9 +272,6 @@
},
"모음 탭" : {
},
"목록과 타임테이블 모두에 필터가 적용돼요." : {
},
"목표" : {
@ -371,12 +375,6 @@
},
"선택" : {
},
"선택한 꼬리표·행동의 기록만 통계에 반영돼요." : {
},
"선택한 꼬리표가 붙은 행동의 기록만 표시돼요." : {
},
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요." : {
@ -497,6 +495,9 @@
},
"적용일" : {
},
"전체 선택" : {
},
"종료" : {
@ -590,9 +591,6 @@
},
"통계" : {
},
"통계 필터" : {
},
"특정 시각 지정" : {
@ -652,9 +650,6 @@
},
"합계" : {
},
"해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : {
},
"행동" : {
@ -692,9 +687,6 @@
},
"행동 탭에서 추적할 행동을 추가해 보세요." : {
},
"행동으로 보기 (여러 개 선택 가능)" : {
},
"현재 진행 중" : {

View File

@ -67,8 +67,8 @@ struct HistoryView: View {
@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> = []
/// " " ( )
@State private var excludedActionIDs: Set<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
@ -125,14 +125,28 @@ struct HistoryView: View {
CountEntryEditorView(entry: entry)
}
.sheet(isPresented: $showingFilter) {
HistoryFilterSheet(
RecordFilterSheet(
title: "기록 필터",
tags: tags,
actions: allActions,
filterTag: $filterTag,
filterActionIDs: $filterActionIDs
excludedActionIDs: $excludedActionIDs
)
}
.onAppear(perform: consumePendingFilter)
.onAppear {
consumePendingFilter()
#if DEBUG
// : -excludeActions "," , -historyShowFilter YES
if let raw = UserDefaults.standard.string(forKey: "excludeActions") {
let names = Set(raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) })
excludedActionIDs = Set(
allActions.filter { names.contains($0.name) }.map(\.persistentModelID)
)
}
if UserDefaults.standard.bool(forKey: "historyShowFilter") {
showingFilter = true
}
#endif
}
.onChange(of: router.pendingHistoryActionID) {
consumePendingFilter()
}
@ -141,42 +155,29 @@ struct HistoryView: View {
/// ' ' ( )
private func consumePendingFilter() {
guard let id = router.pendingHistoryActionID else { return }
filterTag = nil
filterActionIDs = [id]
//
excludedActionIDs = Set(allActions.map(\.persistentModelID)).subtracting([id])
mode = .list
selectedDayKey = math.dayKey(for: .now)
router.pendingHistoryActionID = nil
}
// MARK: ( 1 )
// MARK: ( , )
private var isFiltering: Bool {
filterTag != nil || !filterActionIDs.isEmpty
!excludedActionIDs.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
!excludedActionIDs.contains(action.persistentModelID)
}
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 ""
let names = allActions.filter(passesFilter).map(\.name)
if names.isEmpty { return "표시할 행동 없음" }
return names.count <= 2
? names.joined(separator: ", ")
: "행동 \(names.count)/\(allActions.count)개 표시 중"
}
private var filterChip: some View {
@ -188,8 +189,7 @@ struct HistoryView: View {
.lineLimit(1)
Button {
withAnimation {
filterTag = nil
filterActionIDs = []
excludedActionIDs = []
}
} label: {
Image(systemName: "xmark.circle.fill")
@ -563,106 +563,3 @@ struct TimetableView: View {
}
}
// 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)
}
}

View File

@ -0,0 +1,222 @@
//
// RecordFilterSheet.swift
// Haru_Danim
//
// · () .
// " ID " ,
// .
//
import SwiftUI
import SwiftData
struct RecordFilterSheet: View {
@Environment(\.dismiss) private var dismiss
let title: String
let tags: [Tag]
let actions: [Action]
@Binding var excludedActionIDs: Set<PersistentIdentifier>
private var allActionIDs: Set<PersistentIdentifier> {
Set(actions.map(\.persistentModelID))
}
private var untaggedActions: [Action] {
actions.filter { $0.tags.isEmpty }
}
private func actions(for tag: Tag) -> [Action] {
actions.filter { $0.tags.contains(tag) }
}
var body: some View {
NavigationStack {
List {
Section {
selectAllRow
} footer: {
Text("꼬리표를 누르면 소속 행동이 한 번에 선택·해제되고, 그 안에서 행동을 개별로 켜고 끌 수 있어요.")
}
ForEach(tags) { tag in
let children = actions(for: tag)
if !children.isEmpty {
Section {
tagRow(tag: tag, children: children)
ForEach(children) { action in
actionRow(action)
}
}
}
}
if !untaggedActions.isEmpty {
Section {
untaggedHeaderRow
ForEach(untaggedActions) { action in
actionRow(action)
}
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
}
}
// MARK: (: )
private var selectAllRow: some View {
Button {
if excludedActionIDs.isEmpty {
excludedActionIDs = allActionIDs
} else {
excludedActionIDs = []
}
} label: {
HStack(spacing: 10) {
Image(systemName: "square.grid.2x2")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(AppTheme.green, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text("전체 선택")
.fontWeight(.medium)
.foregroundStyle(.primary)
Spacer()
stateIcon(for: selectionState(of: allActionIDs))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: () /
private func tagRow(tag: Tag, children: [Action]) -> some View {
let childIDs = Set(children.map(\.persistentModelID))
let state = selectionState(of: childIDs)
return Button {
if state == .all {
excludedActionIDs.formUnion(childIDs)
} else {
excludedActionIDs.subtract(childIDs)
}
} label: {
HStack(spacing: 10) {
Image(systemName: "tag.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(tag.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(tag.name)
.fontWeight(.medium)
.foregroundStyle(.primary)
Spacer()
Text("\(childIDs.subtracting(excludedActionIDs).count)/\(childIDs.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
stateIcon(for: state)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var untaggedHeaderRow: some View {
let childIDs = Set(untaggedActions.map(\.persistentModelID))
let state = selectionState(of: childIDs)
return Button {
if state == .all {
excludedActionIDs.formUnion(childIDs)
} else {
excludedActionIDs.subtract(childIDs)
}
} label: {
HStack(spacing: 10) {
Image(systemName: "tag.slash")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Color.gray, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text("꼬리표 없음")
.fontWeight(.medium)
.foregroundStyle(.primary)
Spacer()
Text("\(childIDs.subtracting(excludedActionIDs).count)/\(childIDs.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
stateIcon(for: state)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: () /
private func actionRow(_ action: Action) -> some View {
let id = action.persistentModelID
let selected = !excludedActionIDs.contains(id)
return Button {
if selected {
excludedActionIDs.insert(id)
} else {
excludedActionIDs.remove(id)
}
} label: {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
.background(action.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
Text(action.name)
.font(.subheadline)
.foregroundStyle(.primary)
Spacer()
stateIcon(for: selected ? .all : .none)
}
.padding(.leading, 24)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: /
private enum SelectionState {
case all, partial, none
}
private func selectionState(of ids: Set<PersistentIdentifier>) -> SelectionState {
let excludedCount = ids.intersection(excludedActionIDs).count
if excludedCount == 0 { return .all }
if excludedCount == ids.count { return .none }
return .partial
}
@ViewBuilder
private func stateIcon(for state: SelectionState) -> some View {
switch state {
case .all:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppTheme.green)
case .partial:
Image(systemName: "minus.circle.fill")
.foregroundStyle(AppTheme.yellow)
case .none:
Image(systemName: "circle")
.foregroundStyle(Color.secondary.opacity(0.4))
}
}
}

View File

@ -29,8 +29,8 @@ struct StatsTabView: View {
}()
@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() }
@ -104,13 +104,27 @@ struct StatsTabView: View {
}
}
.sheet(isPresented: $showingFilter) {
StatsFilterSheet(
RecordFilterSheet(
title: "통계 필터",
tags: tags,
actions: allActions,
excludedTagIDs: $excludedTagIDs,
excludedActionIDs: $excludedActionIDs
)
}
.onAppear {
#if DEBUG
// : -excludeActions "," , -statShowFilter YES
if let raw = UserDefaults.standard.string(forKey: "excludeActions") {
let names = Set(raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) })
excludedActionIDs = Set(
allActions.filter { names.contains($0.name) }.map(\.persistentModelID)
)
}
if UserDefaults.standard.bool(forKey: "statShowFilter") {
showingFilter = true
}
#endif
}
}
// MARK:
@ -187,17 +201,15 @@ struct StatsTabView: View {
}
}
// MARK: (· , )
// MARK: ( , )
private var isFiltering: Bool {
!excludedTagIDs.isEmpty || !excludedActionIDs.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) }
!excludedActionIDs.contains(action.persistentModelID)
}
private var filteredActions: [Action] {
@ -213,7 +225,6 @@ struct StatsTabView: View {
.lineLimit(1)
Button {
withAnimation {
excludedTagIDs = []
excludedActionIDs = []
}
} label: {
@ -759,105 +770,3 @@ struct TagStat: Identifiable {
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)
}
}