360 lines
13 KiB
Swift
360 lines
13 KiB
Swift
//
|
||
// ActionViews.swift
|
||
// Haru_Danim
|
||
//
|
||
// 행동 탭: 꼬리표별 그룹 리스트 + 상세 + 추가/수정 (CLAUDE.md §6.2)
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
// MARK: - 행동 리스트 (꼬리표별 그룹)
|
||
|
||
struct ActionListView: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Query(sort: \Tag.createdAt) private var tags: [Tag]
|
||
@Query(sort: \Action.sortOrder) private var actions: [Action]
|
||
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
|
||
|
||
@State private var showingAdd = false
|
||
@State private var showLimitAlert = false
|
||
|
||
private var untaggedActions: [Action] {
|
||
actions.filter { $0.tags.isEmpty }
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
ForEach(tags) { tag in
|
||
if !tag.actions.isEmpty {
|
||
Section {
|
||
ForEach(tag.sortedActions) { action in
|
||
NavigationLink {
|
||
ActionDetailView(action: action)
|
||
} label: {
|
||
ActionRow(action: 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
|
||
NavigationLink {
|
||
ActionDetailView(action: action)
|
||
} label: {
|
||
ActionRow(action: action)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if actions.isEmpty {
|
||
ContentUnavailableView(
|
||
"등록된 행동이 없어요",
|
||
systemImage: "figure.walk",
|
||
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
|
||
)
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("행동")
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
if !isPremium && actions.count >= FreeLimits.actions {
|
||
showLimitAlert = true
|
||
} else {
|
||
showingAdd = true
|
||
}
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingAdd) {
|
||
ActionEditorView(action: nil)
|
||
}
|
||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||
Button("확인", role: .cancel) {}
|
||
} message: {
|
||
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
Text(action.name)
|
||
.font(.body.weight(.medium))
|
||
Text(action.trackingType.label)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
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 {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("수정") { showingEdit = true }
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingEdit) {
|
||
ActionEditorView(action: action)
|
||
}
|
||
.confirmationDialog(
|
||
"‘\(action.name)’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.",
|
||
isPresented: $showingDelete,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("삭제", role: .destructive) {
|
||
context.delete(action)
|
||
dismiss()
|
||
}
|
||
Button("취소", role: .cancel) {}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var statsRows: some View {
|
||
let agg = Aggregator()
|
||
let math = agg.math
|
||
let now = Date.now
|
||
switch action.trackingType {
|
||
case .time:
|
||
statRow("오늘", Format.durationShort(agg.seconds(for: action, in: math.dayRange(containing: now))))
|
||
statRow("이번 주", Format.durationShort(agg.seconds(for: action, in: math.weekRange(containing: now))))
|
||
statRow("이번 달", Format.durationShort(agg.seconds(for: action, in: math.monthRange(containing: now))))
|
||
case .count:
|
||
statRow("오늘", "\(agg.count(for: action, in: math.dayRange(containing: now)))회")
|
||
statRow("이번 주", "\(agg.count(for: action, in: math.weekRange(containing: now)))회")
|
||
statRow("이번 달", "\(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 selectedTags: Set<PersistentIdentifier> = []
|
||
@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)
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
}
|
||
.navigationTitle(action == nil ? "행동 추가" : "행동 수정")
|
||
.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
|
||
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
|
||
} else {
|
||
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)
|
||
}
|
||
dismiss()
|
||
}
|
||
}
|