feat: Implement basic features for tag(꼬리표) and action(행동)
This commit is contained in:
parent
52f45dba2b
commit
da740ade03
Binary file not shown.
183
myApp/HaruDanim/IOS/Views/ActionTabView.swift
Normal file
183
myApp/HaruDanim/IOS/Views/ActionTabView.swift
Normal 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)
|
||||
}
|
||||
@ -15,12 +15,12 @@ struct MainTabView: View {
|
||||
Label("메인", systemImage: "house")
|
||||
}
|
||||
|
||||
Text("행동")
|
||||
ActionTabView()
|
||||
.tabItem {
|
||||
Label("행동", systemImage: "bolt")
|
||||
}
|
||||
|
||||
Text("꼬리표")
|
||||
TagTabView()
|
||||
.tabItem {
|
||||
Label("꼬리표", systemImage: "tag")
|
||||
}
|
||||
|
||||
126
myApp/HaruDanim/IOS/Views/TagTabView.swift
Normal file
126
myApp/HaruDanim/IOS/Views/TagTabView.swift
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user