213 lines
7.3 KiB
Swift
213 lines
7.3 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct TaskTrackerView: View {
|
||
@Environment(\.modelContext) private var modelContext
|
||
@State private var viewModel = TaskViewModel()
|
||
@State private var showAddSheet = false
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Theme.background.ignoresSafeArea()
|
||
Group {
|
||
if viewModel.tasks.isEmpty {
|
||
emptyState
|
||
} else {
|
||
taskList
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(String(localized: "tab.tasks"))
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button {
|
||
showAddSheet = true
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
.foregroundStyle(Theme.green)
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showAddSheet) {
|
||
AddTaskSheet { name, icon, type, category in
|
||
viewModel.addTask(name: name, icon: icon, type: type, category: category)
|
||
}
|
||
}
|
||
.onAppear {
|
||
viewModel.setup(context: modelContext)
|
||
}
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "checkmark.circle.badge.plus")
|
||
.font(.system(size: 56))
|
||
.foregroundStyle(Theme.green.opacity(0.6))
|
||
Text(String(localized: "task.empty"))
|
||
.foregroundStyle(Theme.primaryText.opacity(0.6))
|
||
}
|
||
}
|
||
|
||
private var taskList: some View {
|
||
List {
|
||
ForEach(viewModel.tasks) { task in
|
||
TaskRow(task: task, viewModel: viewModel)
|
||
.listRowBackground(Theme.surface)
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||
Button(role: .destructive) {
|
||
viewModel.deleteTask(task)
|
||
} label: {
|
||
Label(String(localized: "task.delete"), systemImage: "trash")
|
||
}
|
||
}
|
||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||
if task.type == .count {
|
||
Button {
|
||
viewModel.addCount(to: task)
|
||
} label: {
|
||
Label(String(localized: "task.add.count"), systemImage: "plus")
|
||
}
|
||
.tint(Theme.green)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
}
|
||
|
||
// MARK: - Task Row
|
||
|
||
private struct TaskRow: View {
|
||
let task: TaskItem
|
||
let viewModel: TaskViewModel
|
||
|
||
private var isRunning: Bool { viewModel.isTimerRunning(for: task.id) }
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Text(task.icon)
|
||
.font(.title2)
|
||
.frame(width: 36)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(task.name)
|
||
.font(.body)
|
||
.fontWeight(.medium)
|
||
.foregroundStyle(Theme.primaryText)
|
||
if let category = task.category {
|
||
Text(category.name)
|
||
.font(.caption2)
|
||
.foregroundStyle(Theme.green)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if task.type == .time {
|
||
timeControls
|
||
} else {
|
||
countBadge
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
private var timeControls: some View {
|
||
HStack(spacing: 8) {
|
||
if isRunning {
|
||
Text(viewModel.formattedElapsed(for: task.id))
|
||
.font(.system(.subheadline, design: .monospaced))
|
||
.foregroundStyle(Theme.green)
|
||
.contentTransition(.numericText())
|
||
.animation(.linear(duration: 0.3), value: viewModel.elapsedSeconds[task.id])
|
||
}
|
||
Button {
|
||
viewModel.toggleTimer(for: task)
|
||
} label: {
|
||
Image(systemName: isRunning ? "stop.fill" : "play.fill")
|
||
.font(.title3)
|
||
.foregroundStyle(isRunning ? Theme.yellow : Theme.green)
|
||
.frame(width: 36, height: 36)
|
||
.background(
|
||
Circle()
|
||
.fill(isRunning ? Theme.yellow.opacity(0.15) : Theme.green.opacity(0.15))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
private var countBadge: some View {
|
||
let count = viewModel.todayCount(for: task)
|
||
return Text("×\(count)")
|
||
.font(.headline)
|
||
.monospacedDigit()
|
||
.foregroundStyle(count > 0 ? Theme.green : Theme.primaryText.opacity(0.3))
|
||
}
|
||
}
|
||
|
||
// MARK: - Add Task Sheet
|
||
|
||
private struct AddTaskSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Query private var categories: [Category]
|
||
|
||
@State private var name = ""
|
||
@State private var icon = "⭐"
|
||
@State private var type: TaskType = .count
|
||
@State private var selectedCategory: Category?
|
||
|
||
let onAdd: (String, String, TaskType, Category?) -> Void
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
TextField(String(localized: "task.name"), text: $name)
|
||
TextField(String(localized: "task.icon"), text: $icon)
|
||
}
|
||
Section {
|
||
Picker(String(localized: "task.type"), selection: $type) {
|
||
Text(String(localized: "task.type.count")).tag(TaskType.count)
|
||
Text(String(localized: "task.type.time")).tag(TaskType.time)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
if !categories.isEmpty {
|
||
Section {
|
||
Picker(String(localized: "task.category"), selection: $selectedCategory) {
|
||
Text(String(localized: "task.category.none")).tag(Optional<Category>.none)
|
||
ForEach(categories) { cat in
|
||
Text(cat.name).tag(Optional(cat))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(String(localized: "task.add"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(String(localized: "button.cancel")) { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(String(localized: "button.add")) {
|
||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||
guard !trimmed.isEmpty else { return }
|
||
let resolvedIcon = icon.trimmingCharacters(in: .whitespaces).isEmpty ? "⭐" : icon
|
||
onAdd(trimmed, resolvedIcon, type, selectedCategory)
|
||
dismiss()
|
||
}
|
||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
TaskTrackerView()
|
||
}
|