feat: Implement basic features for tag(꼬리표) and action(행동)

This commit is contained in:
songyc macbook 2026-07-06 17:24:57 +09:00
parent 52f45dba2b
commit da740ade03
4 changed files with 311 additions and 2 deletions

View File

@ -0,0 +1,183 @@
//
// ActionTabView.swift
// HaruDanim
//
// Lists all `Action`s and hosts a sheet for creating new ones.
//
import SwiftUI
import SwiftData
struct ActionTabView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Action.createdAt, order: .reverse) private var actions: [Action]
@State private var isAddSheetPresented = false
var body: some View {
NavigationStack {
List {
ForEach(actions) { action in
HStack(spacing: 12) {
Image(systemName: action.iconName)
.frame(width: 28)
.foregroundStyle(Color.brandPrimary)
VStack(alignment: .leading, spacing: 4) {
Text(action.name)
if !action.tags.isEmpty {
Text(action.tags.map(\.name).joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(action.type == .time ? "시간" : "횟수")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: deleteActions)
}
.navigationTitle("행동")
.overlay {
if actions.isEmpty {
ContentUnavailableView(
"행동이 없어요",
systemImage: "bolt",
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
isAddSheetPresented = true
} label: {
Label("행동 추가", systemImage: "plus")
}
}
}
.sheet(isPresented: $isAddSheetPresented) {
AddActionSheet()
}
}
}
private func deleteActions(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(actions[index])
}
}
}
// MARK: - Add Action Sheet
private struct AddActionSheet: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: \Tag.createdAt, order: .reverse) private var allTags: [Tag]
@State private var name = ""
@State private var iconName = "star.fill"
@State private var type: ActionType = .time
@State private var selectedTagIDs: Set<UUID> = []
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationStack {
Form {
Section("이름") {
TextField("행동 이름", text: $name)
}
Section("아이콘 (SF Symbol)") {
HStack {
Image(systemName: iconName.isEmpty ? "questionmark" : iconName)
.foregroundStyle(Color.brandPrimary)
TextField("star.fill", text: $iconName)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
}
Section("유형") {
Picker("유형", selection: $type) {
Text("시간").tag(ActionType.time)
Text("횟수").tag(ActionType.count)
}
.pickerStyle(.segmented)
}
Section("꼬리표") {
if allTags.isEmpty {
Text("먼저 꼬리표를 추가하세요.")
.foregroundStyle(.secondary)
} else {
ForEach(allTags) { tag in
Button {
toggle(tag)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: tag.colorHex))
.frame(width: 16, height: 16)
Text(tag.name)
.foregroundStyle(.primary)
Spacer()
if selectedTagIDs.contains(tag.id) {
Image(systemName: "checkmark")
.foregroundStyle(Color.brandPrimary)
}
}
}
}
}
}
}
.navigationTitle("새 행동")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(trimmedName.isEmpty)
}
}
}
}
private func toggle(_ tag: Tag) {
if selectedTagIDs.contains(tag.id) {
selectedTagIDs.remove(tag.id)
} else {
selectedTagIDs.insert(tag.id)
}
}
private func save() {
let chosenTags = allTags.filter { selectedTagIDs.contains($0.id) }
let trimmedIcon = iconName.trimmingCharacters(in: .whitespacesAndNewlines)
let action = Action(
name: trimmedName,
type: type,
iconName: trimmedIcon.isEmpty ? "circle" : trimmedIcon,
tags: chosenTags
)
modelContext.insert(action)
dismiss()
}
}
#Preview {
ActionTabView()
.modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true)
}

View File

@ -15,12 +15,12 @@ struct MainTabView: View {
Label("메인", systemImage: "house")
}
Text("행동")
ActionTabView()
.tabItem {
Label("행동", systemImage: "bolt")
}
Text("꼬리표")
TagTabView()
.tabItem {
Label("꼬리표", systemImage: "tag")
}

View File

@ -0,0 +1,126 @@
//
// TagTabView.swift
// HaruDanim
//
// Lists all `Tag`s and hosts a sheet for creating new ones.
//
import SwiftUI
import SwiftData
struct TagTabView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Tag.createdAt, order: .reverse) private var tags: [Tag]
@State private var isAddSheetPresented = false
var body: some View {
NavigationStack {
List {
ForEach(tags) { tag in
HStack(spacing: 12) {
Circle()
.fill(Color(hex: tag.colorHex))
.frame(width: 20, height: 20)
Text(tag.name)
Spacer()
}
}
.onDelete(perform: deleteTags)
}
.navigationTitle("꼬리표")
.overlay {
if tags.isEmpty {
ContentUnavailableView(
"꼬리표가 없어요",
systemImage: "tag",
description: Text("오른쪽 위 + 버튼으로 꼬리표를 추가하세요.")
)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
isAddSheetPresented = true
} label: {
Label("꼬리표 추가", systemImage: "plus")
}
}
}
.sheet(isPresented: $isAddSheetPresented) {
AddTagSheet()
}
}
}
private func deleteTags(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tags[index])
}
}
}
// MARK: - Add Tag Sheet
private struct AddTagSheet: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var color = Color(hex: "#2F6B4F")
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationStack {
Form {
Section("이름") {
TextField("꼬리표 이름", text: $name)
}
Section("색상") {
ColorPicker("색상 선택", selection: $color, supportsOpacity: false)
}
}
.navigationTitle("새 꼬리표")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(trimmedName.isEmpty)
}
}
}
}
private func save() {
let tag = Tag(name: trimmedName, colorHex: color.toHex())
modelContext.insert(tag)
dismiss()
}
}
// MARK: - Color hex helper
extension Color {
/// Serializes the color to a `#RRGGBB` string for persistence.
func toHex() -> String {
let resolved = resolve(in: EnvironmentValues())
return String(
format: "#%02X%02X%02X",
Int((resolved.red * 255).rounded()),
Int((resolved.green * 255).rounded()),
Int((resolved.blue * 255).rounded())
)
}
}
#Preview {
TagTabView()
.modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true)
}