// // 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 showingTagOrder = 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") } } label: { Image(systemName: "ellipsis.circle") } } ToolbarItem(placement: .topBarTrailing) { Button { if !premium.canAddAction(currentCount: actions.count) { showLimitAlert = true } else { showingAdd = true } } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showingAdd) { ActionEditorView(action: nil) } .sheet(isPresented: $showingTagOrder) { TagOrderSheet() } .alert("무료 사용 한도", isPresented: $showLimitAlert) { Button("확인", role: .cancel) {} } message: { Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.") } } private func actionLink(_ action: Action) -> some View { NavigationLink { ActionDetailView(action: action) } label: { ActionRow(action: action) } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { action.isFavorite.toggle() } 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 @Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag] 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 } } } .environment(\.editMode, .constant(.active)) .navigationTitle("꼬리표 순서") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { dismiss() } } } } .presentationDetents([.medium, .large]) } } // 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: Bindable(action).isFavorite) { 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() } 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() } }