- Initialize iOS project with 6-tab navigation structure - Configure custom Light/Dark themes and AppColors - Define SwiftData models for Tasks, Tags, Goals, and TrackingRecords - Setup relationships (Super/Sub tags) and cascade delete rules - Implement Observable TrackingEngine for real-time timer updates
261 lines
9.0 KiB
Swift
261 lines
9.0 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct TasksTabView: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Query(sort: \TaskItem.sortOrder) private var tasks: [TaskItem]
|
||
@State private var showAddSheet = false
|
||
@State private var editTarget: TaskItem?
|
||
|
||
private var activeTasks: [TaskItem] { tasks.filter { !$0.isArchived } }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Group {
|
||
if activeTasks.isEmpty {
|
||
EmptyStateView(
|
||
icon: "checkmark.circle",
|
||
message: String(localized: "tasks.empty")
|
||
)
|
||
} else {
|
||
List {
|
||
ForEach(activeTasks) { task in
|
||
TaskRowView(task: task)
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||
Button(role: .destructive) {
|
||
context.delete(task)
|
||
} label: {
|
||
Label("삭제", systemImage: "trash")
|
||
}
|
||
Button {
|
||
editTarget = task
|
||
} label: {
|
||
Label("편집", systemImage: "pencil")
|
||
}
|
||
.tint(.blue)
|
||
}
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
}
|
||
.background(Color.tfBackground)
|
||
.navigationTitle(String(localized: "tab.tasks"))
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button { showAddSheet = true } label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
.tint(Color.tfPrimary)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showAddSheet) {
|
||
TaskFormView()
|
||
}
|
||
.sheet(item: $editTarget) { task in
|
||
TaskFormView(existing: task)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: – Row
|
||
|
||
private struct TaskRowView: View {
|
||
let task: TaskItem
|
||
|
||
private var primaryColor: Color {
|
||
task.tags.first.map { Color(hex: $0.colorHex) } ?? Color.tfPrimary
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: task.icon)
|
||
.foregroundStyle(primaryColor)
|
||
.frame(width: 28)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(task.name)
|
||
.foregroundStyle(Color.tfOnBackground)
|
||
HStack(spacing: 4) {
|
||
Text(task.taskType == .timer ? "타이머" : "카운터")
|
||
.font(.caption)
|
||
.foregroundStyle(Color.tfMuted)
|
||
if let firstTag = task.tags.first {
|
||
Text("·")
|
||
.font(.caption)
|
||
.foregroundStyle(Color.tfMuted)
|
||
Text(firstTag.name)
|
||
.font(.caption)
|
||
.foregroundStyle(Color(hex: firstTag.colorHex))
|
||
}
|
||
}
|
||
}
|
||
Spacer()
|
||
Image(systemName: task.taskType == .timer ? "timer" : "number")
|
||
.foregroundStyle(primaryColor)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
}
|
||
|
||
// MARK: – Form
|
||
|
||
struct TaskFormView: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Query(sort: \TagEntity.sortOrder) private var allTags: [TagEntity]
|
||
|
||
var existing: TaskItem? = nil
|
||
|
||
@State private var name = ""
|
||
@State private var icon = "star"
|
||
@State private var taskType: TaskType = .timer
|
||
@State private var selectedTagIDs: Set<UUID> = []
|
||
|
||
private let iconPresets = [
|
||
"star", "heart", "bolt", "flame",
|
||
"book.fill", "pencil", "laptopcomputer", "dumbbell.fill",
|
||
"music.note", "camera.fill", "gamecontroller.fill", "fork.knife",
|
||
"figure.walk", "bicycle", "car.fill", "airplane",
|
||
"moon.stars.fill", "sun.max.fill", "drop.fill", "leaf.fill"
|
||
]
|
||
|
||
private var isEditing: Bool { existing != nil }
|
||
private var isSaveDisabled: Bool { name.trimmingCharacters(in: .whitespaces).isEmpty }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("이름") {
|
||
TextField("작업 이름", text: $name)
|
||
}
|
||
|
||
Section("아이콘") {
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 12) {
|
||
ForEach(iconPresets, id: \.self) { sym in
|
||
Image(systemName: sym)
|
||
.font(.title2)
|
||
.foregroundStyle(sym == icon ? Color.tfPrimary : Color.tfMuted)
|
||
.frame(width: 44, height: 44)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(sym == icon ? Color.tfPrimary.opacity(0.15) : Color.clear)
|
||
)
|
||
.onTapGesture { icon = sym }
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
Section("유형") {
|
||
Picker("유형", selection: $taskType) {
|
||
Text("타이머").tag(TaskType.timer)
|
||
Text("카운터").tag(TaskType.counter)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
|
||
if !allTags.isEmpty {
|
||
Section("태그") {
|
||
ForEach(allTags) { tag in
|
||
TagSelectionRow(
|
||
tag: tag,
|
||
isSelected: selectedTagIDs.contains(tag.id),
|
||
onToggle: { toggleTag(tag) }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(isEditing ? "작업 편집" : "작업 추가")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("취소") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("저장") { save() }
|
||
.disabled(isSaveDisabled)
|
||
}
|
||
}
|
||
.onAppear { loadExisting() }
|
||
}
|
||
}
|
||
|
||
private func loadExisting() {
|
||
guard let task = existing else { return }
|
||
name = task.name
|
||
icon = task.icon
|
||
taskType = task.taskType
|
||
selectedTagIDs = Set(task.tags.map { $0.id })
|
||
}
|
||
|
||
private func toggleTag(_ tag: TagEntity) {
|
||
if selectedTagIDs.contains(tag.id) {
|
||
selectedTagIDs.remove(tag.id)
|
||
} else {
|
||
selectedTagIDs.insert(tag.id)
|
||
// Selecting a sub tag implicitly includes its parent super tag
|
||
if let parent = tag.parent {
|
||
selectedTagIDs.insert(parent.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||
guard !trimmed.isEmpty else { return }
|
||
|
||
let selectedTags = allTags.filter { selectedTagIDs.contains($0.id) }
|
||
|
||
if let task = existing {
|
||
task.name = trimmed
|
||
task.icon = icon
|
||
task.taskType = taskType
|
||
task.tags = selectedTags
|
||
} else {
|
||
let newTask = TaskItem(name: trimmed, icon: icon, taskType: taskType)
|
||
newTask.tags = selectedTags
|
||
context.insert(newTask)
|
||
}
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
private struct TagSelectionRow: View {
|
||
let tag: TagEntity
|
||
let isSelected: Bool
|
||
let onToggle: () -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
if tag.isSubTag {
|
||
Spacer().frame(width: 14)
|
||
}
|
||
Circle()
|
||
.fill(Color(hex: tag.colorHex))
|
||
.frame(width: 10, height: 10)
|
||
Text(tag.name)
|
||
.foregroundStyle(Color.tfOnBackground)
|
||
if tag.isSubTag, let parent = tag.parent {
|
||
Text("(\(parent.name))")
|
||
.font(.caption)
|
||
.foregroundStyle(Color.tfMuted)
|
||
}
|
||
Spacer()
|
||
if isSelected {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(Color.tfPrimary)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { onToggle() }
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
TasksTabView()
|
||
.modelContainer(for: [TaskItem.self, TagEntity.self, TrackingRecord.self], inMemory: true)
|
||
.environment(AppPreferences())
|
||
}
|