// // ActionViews.swift // Haru_Danim // // 행동 탭: 꼬리표별 그룹 리스트 + 상세 + 추가/수정 (CLAUDE.md §6.2) // import SwiftUI import SwiftData // MARK: - 행동 리스트 (꼬리표별 그룹) struct ActionListView: View { @Environment(\.modelContext) private var context @Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag] @Query(sort: \Action.createdAt) private var allActions: [Action] private let premium = PremiumManager.shared @State private var showingAdd = false @State private var showLimitAlert = false @State private var showingPremiumSheet = false @State private var showingTagOrder = false @State private var showingFavoriteOrder: Bool = { #if DEBUG // 검증용: -actionShowFavoriteOrder YES → 즐겨찾기 순서 시트 바로 표시 if UserDefaults.standard.bool(forKey: "actionShowFavoriteOrder") { return true } #endif return false }() // 행동 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고) @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" private var actions: [Action] { LocalPrefs.orderedActions(allActions, raw: actionOrderRaw) } private var favoriteActions: [Action] { actions.filter(\.isFavorite) } private var untaggedActions: [Action] { actions.filter { $0.tags.isEmpty && !$0.isFavorite } } var body: some View { List { if !favoriteActions.isEmpty { Section { ForEach(favoriteActions) { action in actionLink(action) } } header: { Label("즐겨찾기", systemImage: "star.fill") .foregroundStyle(AppTheme.yellow) } } ForEach(tags) { tag in let group = actions.filter { $0.tags.contains(tag) && !$0.isFavorite } if !group.isEmpty { Section { ForEach(group) { action in actionLink(action) } } header: { HStack(spacing: 6) { Circle().fill(tag.color).frame(width: 10, height: 10) Text(tag.name) } } } } if !untaggedActions.isEmpty { Section("꼬리표 없음") { ForEach(untaggedActions) { action in actionLink(action) } } } if actions.isEmpty { ContentUnavailableView( "등록된 행동이 없어요", systemImage: "figure.walk", description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.") ) } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("행동") .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { Button { showingTagOrder = true } label: { Label("꼬리표 순서 변경", systemImage: "arrow.up.arrow.down") } // 순서 변경은 2개 이상일 때만 의미가 있다 if favoriteActions.count >= 2 { Button { showingFavoriteOrder = true } label: { Label("즐겨찾기 순서 변경", systemImage: "star.fill") } } } label: { Image(systemName: "ellipsis.circle") } .accessibilityLabel(Text("메뉴")) } ToolbarItem(placement: .topBarTrailing) { Button { if !premium.canAddAction(currentCount: actions.count) { showLimitAlert = true } else { showingAdd = true } } label: { Image(systemName: "plus") } .accessibilityLabel(Text("행동 추가")) } } .sheet(isPresented: $showingAdd) { ActionEditorView(action: nil) } .sheet(isPresented: $showingTagOrder) { TagOrderSheet() } .sheet(isPresented: $showingFavoriteOrder) { FavoriteOrderSheet() } .alert("무료 사용 한도", isPresented: $showLimitAlert) { Button("프리미엄 알아보기") { showingPremiumSheet = true } Button("확인", role: .cancel) {} } message: { Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.") } .sheet(isPresented: $showingPremiumSheet) { PremiumSheetView() } } private func actionLink(_ action: Action) -> some View { NavigationLink { ActionDetailView(action: action) } label: { ActionRow(action: action) } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { action.isFavorite.toggle() // 새 즐겨찾기는 모음 탭 배치 순서상 즐겨찾기 블록 끝으로 (§6.1 "지정한 순서" 규칙) if action.isFavorite { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) } DataChange.commit(context: context) } label: { Label( action.isFavorite ? String(localized: "즐겨찾기 해제") : String(localized: "즐겨찾기"), systemImage: action.isFavorite ? "star.slash" : "star.fill" ) } .tint(AppTheme.yellow) } } } struct ActionRow: View { let action: Action var body: some View { HStack(spacing: 12) { Image(systemName: action.symbolName) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.white) .frame(width: 34, height: 34) .background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(action.name) .font(.body.weight(.medium)) if action.isFavorite { Image(systemName: "star.fill") .font(.caption2) .foregroundStyle(AppTheme.yellow) } } Text(action.trackingType.label) .font(.caption) .foregroundStyle(.secondary) } } } } // MARK: - 꼬리표 순서 변경 시트 (행동 탭 그룹 순서에 반영) struct TagOrderSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context @Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag] /// 드래그로 순서가 바뀌었는지 — 시트가 닫힐 때 한 번만 커밋해 /// 드래그마다 위젯·워치 갱신이 연발되지 않게 한다 @State private var orderChanged = false var body: some View { NavigationStack { List { ForEach(tags) { tag in HStack(spacing: 10) { Circle().fill(tag.color).frame(width: 14, height: 14) Text(tag.name) } } .onMove { source, destination in var ordered = tags ordered.move(fromOffsets: source, toOffset: destination) for (index, tag) in ordered.enumerated() { tag.sortOrder = index } orderChanged = true } } .environment(\.editMode, .constant(.active)) .navigationTitle("꼬리표 순서") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { dismiss() } } } } .presentationDetents([.medium, .large]) .onDisappear { // 꼬리표 순서는 워치 앱 그룹핑 순서에도 쓰이므로 닫힐 때 커밋 // ('완료' 버튼과 스와이프 내리기 둘 다 이 경로를 지난다) if orderChanged { DataChange.commit(context: context) } } } } // MARK: - 즐겨찾기 순서 변경 시트 (모음 탭 즐겨찾기 섹션·애플워치 즐겨찾기에 반영) struct FavoriteOrderSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context @Query(sort: \Action.createdAt) private var allActions: [Action] // 이동 즉시 목록이 새 순서로 그려지도록 배치 순서를 관찰한다 @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" /// 드래그로 순서가 바뀌었는지 — 시트가 닫힐 때 한 번만 커밋해 /// 드래그마다 위젯·워치 갱신이 연발되지 않게 한다 (TagOrderSheet와 동일 패턴) @State private var orderChanged = false private var favorites: [Action] { LocalPrefs.orderedActions(allActions, raw: actionOrderRaw).filter(\.isFavorite) } var body: some View { NavigationStack { List { Section { ForEach(favorites) { action in HStack(spacing: 10) { Image(systemName: action.symbolName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(action.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) Text(action.name) .lineLimit(1) } } .onMove { source, destination in LocalPrefs.reorderFavorites(from: source, to: destination, context: context) orderChanged = true } } footer: { Text("손잡이를 끌어 순서를 바꿔요. 모음 탭 '즐겨찾기'와 애플워치의 즐겨찾기 목록에 같은 순서로 적용돼요.") } } .environment(\.editMode, .constant(.active)) .navigationTitle("즐겨찾기 순서") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { dismiss() } } } } .presentationDetents([.medium, .large]) .onDisappear { // 순서는 기기 로컬 설정이지만 위젯·워치 스냅숏이 이를 읽는다 — 닫힐 때 갱신 경로 1회 if orderChanged { DataChange.commit(context: context) } } } } // MARK: - 행동 상세 struct ActionDetailView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss let action: Action @State private var showingEdit = false @State private var showingDelete = false var body: some View { List { Section("기본 정보") { HStack { Text("이름") Spacer() Text(action.name).foregroundStyle(.secondary) } // 리스트 스와이프 토글과 동일하게 변경 즉시 커밋 (저장 확정) Toggle(isOn: Binding( get: { action.isFavorite }, set: { action.isFavorite = $0 // 새 즐겨찾기는 모음 탭 배치 순서상 즐겨찾기 블록 끝으로 (§6.1 "지정한 순서" 규칙) if $0 { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) } DataChange.commit(context: context) } )) { Label { Text("즐겨찾기") } icon: { Image(systemName: "star.fill") .foregroundStyle(AppTheme.yellow) } } // 메모 창 표시 여부는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고) Toggle(isOn: Binding( get: { LocalPrefs.promptsForNote(action.uuid) }, set: { LocalPrefs.setPromptsForNote(action.uuid, enabled: $0) } )) { Label { Text("기록 시 메모 창 표시") } icon: { Image(systemName: "note.text") .foregroundStyle(AppTheme.green) } } HStack { Text("아이콘") Spacer() Image(systemName: action.symbolName) .foregroundStyle(.white) .frame(width: 30, height: 30) .background(action.color, in: RoundedRectangle(cornerRadius: 8)) } HStack { Text("추적 방식") Spacer() Label(action.trackingType.label, systemImage: action.trackingType.symbol) .foregroundStyle(.secondary) } } Section("꼬리표") { if action.tags.isEmpty { Text("지정된 꼬리표 없음").foregroundStyle(.secondary) } else { ForEach(action.sortedTags) { tag in HStack(spacing: 8) { Circle().fill(tag.color).frame(width: 10, height: 10) Text(tag.name) } } } } Section("누적 기록") { statsRows } Section { Button("행동 삭제", role: .destructive) { showingDelete = true } } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle(action.name) .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("수정") { showingEdit = true } } } .sheet(isPresented: $showingEdit) { ActionEditorView(action: action) } .alert("행동 삭제", isPresented: $showingDelete) { Button("삭제", role: .destructive) { context.delete(action) DataChange.commit(context: context) dismiss() } Button("취소", role: .cancel) {} } message: { Text("‘\(action.name)’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.") } } @ViewBuilder private var statsRows: some View { let agg = Aggregator() let math = agg.math let now = Date.now switch action.trackingType { case .time: statRow(String(localized: "오늘"), Format.durationShort(agg.seconds(for: action, in: math.dayRange(containing: now)))) statRow(String(localized: "이번 주"), Format.durationShort(agg.seconds(for: action, in: math.weekRange(containing: now)))) statRow(String(localized: "이번 달"), Format.durationShort(agg.seconds(for: action, in: math.monthRange(containing: now)))) case .count: statRow(String(localized: "오늘"), String(localized: "\(agg.count(for: action, in: math.dayRange(containing: now)))회")) statRow(String(localized: "이번 주"), String(localized: "\(agg.count(for: action, in: math.weekRange(containing: now)))회")) statRow(String(localized: "이번 달"), String(localized: "\(agg.count(for: action, in: math.monthRange(containing: now)))회")) } } private func statRow(_ title: String, _ value: String) -> some View { HStack { Text(title) Spacer() Text(value).foregroundStyle(.secondary) } } } // MARK: - 행동 추가/수정 struct ActionEditorView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @Query(sort: \Tag.createdAt) private var allTags: [Tag] @Query private var allActions: [Action] /// nil이면 새 행동 추가 let action: Action? @State private var name = "" @State private var symbolName = "star.fill" @State private var trackingType: TrackingType = .time @State private var promptsForNote = false @State private var selectedTags: Set = [] @State private var showingSymbolPicker = false @State private var showingNewTag = false var body: some View { NavigationStack { Form { Section("이름") { TextField("행동 이름 (예: 독서)", text: $name) } Section("아이콘") { Button { showingSymbolPicker = true } label: { HStack { Image(systemName: symbolName) .font(.system(size: 20)) .foregroundStyle(AppTheme.green) .frame(width: 36, height: 36) Text("아이콘 선택") Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } Section { ForEach(allTags) { tag in Button { toggleTag(tag) } label: { HStack(spacing: 8) { Circle().fill(tag.color).frame(width: 12, height: 12) Text(tag.name) .foregroundStyle(.primary) Spacer() if selectedTags.contains(tag.persistentModelID) { Image(systemName: "checkmark") .foregroundStyle(AppTheme.green) .fontWeight(.semibold) } } .contentShape(Rectangle()) } } Button { showingNewTag = true } label: { Label("새 꼬리표 만들기", systemImage: "plus.circle") .foregroundStyle(AppTheme.green) } } header: { Text("꼬리표 (복수 선택 가능)") } footer: { Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요. 여러 개를 지정하면 가장 먼저 만든 꼬리표의 색이 쓰여요.") } Section { Picker("추적 방식", selection: $trackingType) { ForEach(TrackingType.allCases) { type in Label(type.label, systemImage: type.symbol).tag(type) } } .pickerStyle(.inline) .labelsHidden() // 기존 행동의 추적 방식을 바꾸면 이미 쌓인 기록이 화면·통계에서 // 보이지 않는 것처럼 되므로 생성 시에만 선택 가능 (도움말 안내와 일치) .disabled(action != nil) } header: { Text("추적 방식") } footer: { if action != nil { Text("추적 방식은 행동을 만들 때만 선택할 수 있어요.") } } Section { Toggle(isOn: $promptsForNote) { Label { Text("기록 시 메모 창 표시") } icon: { Image(systemName: "note.text") .foregroundStyle(AppTheme.green) } } } footer: { Text(trackingType == .time ? String(localized: "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요.") : String(localized: "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요.")) } } .navigationTitle(action == nil ? String(localized: "행동 추가") : String(localized: "행동 수정")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("저장") { save() } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) } } .sheet(isPresented: $showingSymbolPicker) { SymbolPickerView(selection: $symbolName) } .sheet(isPresented: $showingNewTag) { TagEditorView(tag: nil) } .onAppear(perform: load) } } private func load() { guard let action else { return } name = action.name symbolName = action.symbolName trackingType = action.trackingType promptsForNote = LocalPrefs.promptsForNote(action.uuid) selectedTags = Set(action.tags.map(\.persistentModelID)) } private func toggleTag(_ tag: Tag) { if selectedTags.contains(tag.persistentModelID) { selectedTags.remove(tag.persistentModelID) } else { selectedTags.insert(tag.persistentModelID) } } private func save() { let tags = allTags.filter { selectedTags.contains($0.persistentModelID) } let trimmed = name.trimmingCharacters(in: .whitespaces) if let action { action.name = trimmed action.symbolName = symbolName action.trackingType = trackingType action.tags = tags LocalPrefs.setPromptsForNote(action.uuid, enabled: promptsForNote) } else { // sortOrder는 로컬 배치 순서가 없는 기기(첫 실행·다른 기기)에서의 폴백 정렬용으로만 기록 let maxOrder = allActions.map(\.sortOrder).max() ?? -1 let newAction = Action( name: trimmed, symbolName: symbolName, trackingType: trackingType, sortOrder: maxOrder + 1 ) newAction.tags = tags context.insert(newAction) // 이 기기의 로컬 배치 맨 끝에 추가 + 메모 창 설정 저장 LocalPrefs.appendActionToOrder(newAction.uuid) LocalPrefs.setPromptsForNote(newAction.uuid, enabled: promptsForNote) } DataChange.commit(context: context) dismiss() } }