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 95637a0..d67e6c3 100644 Binary files a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ 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) - } -}