From 7c85cadd8e901188c8fd1f0605856952914f337e Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Thu, 9 Jul 2026 19:02:30 +0900 Subject: [PATCH] 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 --- .../UserInterfaceState.xcuserstate | Bin 22031 -> 22039 bytes myApp/Haru_Danim/IOS/Localizable.xcstrings | 36 ++- myApp/Haru_Danim/IOS/Views/HistoryView.swift | 165 +++---------- .../IOS/Views/RecordFilterSheet.swift | 222 ++++++++++++++++++ myApp/Haru_Danim/IOS/Views/StatsTabView.swift | 135 ++--------- 5 files changed, 289 insertions(+), 269 deletions(-) create mode 100644 myApp/Haru_Danim/IOS/Views/RecordFilterSheet.swift 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 95637a0a0b55d012a5b69f4d96cd153495acf394..d67e6c3ed71570dbddba2f1512e5a7a8bcf0a7be 100644 GIT binary patch delta 6764 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 Ke&4r^>irM;<8Frl delta 6694 zcmZ`+2V9fK`+raFj%Wx;2niYRCO`-oZ#LNpA*kS9xavm5EiRl@lXKLzYKz8EZEFSG zTd~%5|&~amSY;Lu@>8~9p_*Ng9A7h2XRAO zfScmBxE(ISop4v&0}sN3@en)|m*Nq)43EVVaC!7WT$*bQUW?b^^>_o`h&SQQ_zS!b z@5f)`Z}7MHJA4A4#5MRwd=B5ncko?Yi|^t4_z`|wT>#@bXwRSzkH4yJmynSVS1#a1 z$4bX#fm&*3WKP5t$?DN}Sf9~DOG?XHl$7=_8_;#gz|xYYrF~1rm5i!RmW2SQt?nqV zMY0u8A)qe-k?Ps{VL;J4u9yHwKpX+}s$e{L3F{M}k1Qg<5KUnQ^6n5|jFeY` zTF@^tCknDf(WQ(6)JAtPBNB56h$kRW=T57rf)pI|}kiM&sMy{>T!vQJSwDv{#D z7O*uXse~hn0p3Vfbc8!Idd!ez>$i{f8x8^e2=KFxCr8Uo z4T2-#c#2s8N5L{U8jgWu;Wz?v2?!F9M?i>xd;%J-fD?l2pKj1hlAtERZduFU;w( zC9r}WRZjqyMm{B=btUKrheRr4zU^gX=9O?A#VmuX;A*%At|fp)r5yo91hiiU*TW5P zBiuwl2Ld`0(20QF(TSFmq3!TXidnieVyJly?k;M71>8+Q=ZO0UxEC_Ej8&hbg8K9)jN!(2aoZmGB5WNqY;}5Rl#%cd~~ucFLV)Js`KJalCQw4|L(W}A5zR(coY5t zZ^2*TZFmRXg|+Y=ybm7`P!j#aMqAdxN0f$Av$#KvW8m-9dG_rkr6FK=gdgF>GBENr z*(TMIZE|%>djupkV0}Ye0!BpKuQ5Q0(VuhD6k;T0p+*vvLck~j$|{iz$q5)uz}RSo zrl|laQAW%_6=TVmNO^R)BQryb7?yQNhxEum085MU1Wc$xMr4Yy?rj1lM*T8^k%QtR zXVmZ1AP@WIjrMmk*#VUMuaHp)HF}+F)G(Ipa3!@fu1mxfZLL*6)C?6xOSOy)wMQLL z$GGyiIdOC2qH*&GAOuVyU}}_hr6l%Cxd1bh-PyfGPy#z!wOmN1%7hbo#J$Dl|IRWvn*>N{^xWy_Q;;b}V=l#1#IX~nSKtH!mjA;@%*TR%l?WDLF^dUK zCSYX+mJqNi+SaEpRSZKBpuq%4OH!IDFJ#1{)h1tzyGXYyFu^0OYs3zd^h#^|+ zOO7t`^9JQnd>q30omv%{$_5TCi6OMV!{B2&0=Cvr6RAnK5pLYERZ*kjvXV;Nh>FzU z*oZ=$AG7SE%Mkln%Qwz7!!2V=HpeXp_=14#mADmdO~97~?EGiR1Tj0lJ??-zzQwLN zs%+%I(*6YOh^`D6qQe8&+8MvqQ^0Rk)N*uLaql4|MXwF2aA(|wiga#ORQJ2)?>V~H zjkKG4;=U|va4%epdt-L*-%Y?C0=^<(?=oD1`{Dk000H|5*iXRM1bh>%pL-@83uM^q zKwfq3{mxyMO1M@93gfUE3$(cPhs@(y5jGyUI|NAOX6jDYI|+#ulQYeAu2fBI0Wta_BH2A^U9 z{{f$_o}Vv+;a}?Lbq4>`{hwsBEGw?UXYg4nQpdUCH_SSZ+5L6}>)l0s3Gc+0G3yZP z%J%2~zP z%-O^FnsbEn3+Fe^GtP6)ADqAHQ}yfD*VNb6*Ec9^&?ekqe1k}X)!amG3fIWZ;fA;c z+$P*++}7N-+#+rV?j&v{cO!Q@cL#SDcMo?j_Yn6m_bB%`_ayfe_cHey_XhVC_cr$~ z_X+nI_j$sGgf9}lNjQ{nIN@kQO~MZeKPLRdtH;yu%sdOv#`EzS@|y74^4js*^Sbkf z@J8__^1|=&mhhJHDtXIzD|oATYk2E;8+e;|TX+X}hj@p1M|sD2CwZrMr+H_1XL;v& z7Za&OV`7`cu8AaZQR2qLn#3!KPx$rt626Xa;2Zg7zMJ2O-<02hKZrksKb`*(el%C2dUFn)Gwht)x3iwMqAr{t(p{aYZ~4UlbOK zL?W>$L!=gIL|Tzv#E48Hi>QsLn`n?|vZ!2CDOx7lEZQ#GA=)L{Bl=c!Ky*lSSaei$ zRrDfRm@H3DOI9YQCl@65PJSo(qvUPLN0Kim-xW6yCx{cp0&$W!Su7Dt#d2|~*dca_ zJz}3YAP$N{;%4F^aR+gyUgEdJUB%tSgTzC`rQ+e@N#d}Wh^LCn#dF2;#0$h#;^pF% z;??49;_c!c;$7lH;*;Ww;-AG=#MdN2NfSvcNs(lrWRYZrWT#}eO35QRsiR-_(F{WDFQ);aC{v@g@XOFNWyIPF;4iL{!un`!sc zeoK3r_Iuh3I-X9X1#}XfOe^R#T1ltVnY5M8rQ6b7=>BvmJ)9m%m(g$2ljtx_=&AH9 zdJ(;t{+Rxh{)}EnZ=^TVTj_1|c6uNE9et4go<2gKr7zQ0!}N9fCS6P4rytUf=_m9n zB}XY%rYdQrN|~Y5DGf@a(yX*98!B5U-%@r{_D~ip`zTA4Q zGUYDiCFLCzR&i7fR0*m?l|YrGN>)i!Qk7hls-jgYl}{B=1y%W~Mydi;Q&n?SD^*)n zxV@^As*9?ds)uU4YJsXs^}Xs*I!u?RyV9Gd_fDUmzA}AJ`hoPC^dHiHO#dnUT>6Fd zOX+vgYt!$iKg?*H(JrHXM#qfK8EZ4PXY9z>m9ZyNkeQaL%uLVB%$%G#Gjm?%g3OOH z7iWHwxjb`a=9DC(537%t z0L=)^D9vciSj~9N+nPz5ux6HKjwY&^uUV}5MDv-ZLbFG6MRQN{So1{lO!J54RW`_u z%TCG`XJ=&VvYBjiwk_L}?GI!mB!_0FV=seU!t$jKQ>SXu_47EGbjvc2Bjh0kZH&=WE*sbTtmpv z(9qaW7&bIBv@oB%%m_X#>nI``Al=BGc%YO%1mIw%oJuC^Byyk znazC2EMe9%o0u)k=gbaf7qf@i%Y=_J7nsM)6XqH7oO#Lo$-FWGW1JBgr5L>TTGvuwwt~+9W;G!I%+y$I%PU-I%B$P=9;zU z0`ma#H1llpV)JL_O7n8_8uNPdCi52aSLP$;ljd{g^X6OTJLY@lhvwhRPc3m4o<(j^ zTGB0Qi_T)OgiRKUC1`13>0s$>>1yd=DYgu<47CimjIxZejI*q?oU~F_oi%7}VQpz` zWo=`9%i7J_(^_mDX`N^dTc=p3Ti>;ww_dkCv_7>yxBg)RHfW2t)wc<4Qd_!BZOgXl zY&o`Owj$do+XUNU+a}v?+aB9jwtco^w&S)FVOx#uXWLWTbK4)bSN3{#Warqq_C&kT zo@`ICo9tG*-R`iv>|T4oo@dXuH?kMno7$V(2iRxW*V>QTALgXwd><1a=f4TUsB$wDFbtzo5 zE8V4bWxI4Po6GKUx`M8}u&be~k88MVv}>$uylb+HxTd+@aYbEgT0q(ZR z!FIt8!Op?1!5+clVBcW>;Dq4BU^qyE(}FXC?+0fG%Y$=+^MaoRw*`*}uLhszCFV)< zEO|}y+U3p2o1M2fZ+YI9yztJvJ$d``zR5d~cOvgp-j8`_^Dg9F%6k?{3K>JaLj6KR zL&HMjLgCPq(Dcx|q4z@zLrX#xp=F_!p$(zUp{=1WLSKfi<_q%u`Hk}n^V{cl&F_(4 zoZmNpaDHk2i2SnrG5O*Ack(~XUs$lBV0FQkg1rR?3XT<=FSu23r{G?}!xja<6+A0= zQ5av?pfI73Uzk*wTqr4&76uA?7tSi&R`^xn!NQY;KNLP|LNx((7t?z6>b~*Qy6-n_ Hleqr^xUEpR diff --git a/myApp/Haru_Danim/IOS/Localizable.xcstrings b/myApp/Haru_Danim/IOS/Localizable.xcstrings index 663cb93..b8e31d7 100644 --- a/myApp/Haru_Danim/IOS/Localizable.xcstrings +++ b/myApp/Haru_Danim/IOS/Localizable.xcstrings @@ -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 @@ }, "행동 탭에서 추적할 행동을 추가해 보세요." : { - }, - "행동으로 보기 (여러 개 선택 가능)" : { - }, "현재 진행 중" : { diff --git a/myApp/Haru_Danim/IOS/Views/HistoryView.swift b/myApp/Haru_Danim/IOS/Views/HistoryView.swift index 8afc7a5..87a6a84 100644 --- a/myApp/Haru_Danim/IOS/Views/HistoryView.swift +++ b/myApp/Haru_Danim/IOS/Views/HistoryView.swift @@ -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 = [] + /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택 (통계 탭과 동일한 모델) + @State private var excludedActionIDs: Set = [] private var math: DayMath { DayMath() } private var dayRange: Range { 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 - - 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/RecordFilterSheet.swift b/myApp/Haru_Danim/IOS/Views/RecordFilterSheet.swift new file mode 100644 index 0000000..0630a5e --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/RecordFilterSheet.swift @@ -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 + + private var allActionIDs: Set { + 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) -> 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)) + } + } +} diff --git a/myApp/Haru_Danim/IOS/Views/StatsTabView.swift b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift index 29e5568..3880d4f 100644 --- a/myApp/Haru_Danim/IOS/Views/StatsTabView.swift +++ b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift @@ -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 = [] + /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택이고, 새로 만든 행동도 자동 포함됨. + /// 꼬리표는 시트에서 소속 행동 일괄 토글용일 뿐, 판정은 행동 단위로만 한다. @State private var excludedActionIDs: Set = [] 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 - @Binding var excludedActionIDs: Set - - 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) { - 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) - } -}