mycode/myApp/Haru_Danim/IOS/Views/ActionViews.swift
2026-07-07 15:20:53 +09:00

360 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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()
}
}