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:
parent
ad9af22e9d
commit
7c85cadd8e
Binary file not shown.
@ -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 @@
|
||||
},
|
||||
"행동 탭에서 추적할 행동을 추가해 보세요." : {
|
||||
|
||||
},
|
||||
"행동으로 보기 (여러 개 선택 가능)" : {
|
||||
|
||||
},
|
||||
"현재 진행 중" : {
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
222
myApp/Haru_Danim/IOS/Views/RecordFilterSheet.swift
Normal file
222
myApp/Haru_Danim/IOS/Views/RecordFilterSheet.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user