- 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
249 lines
8.4 KiB
Swift
249 lines
8.4 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct GoalsTabView: View {
|
||
@Query(sort: \GoalEntity.createdAt) private var goals: [GoalEntity]
|
||
@State private var showAddSheet = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Group {
|
||
if goals.isEmpty {
|
||
EmptyStateView(
|
||
icon: "target",
|
||
message: String(localized: "goals.empty")
|
||
)
|
||
} else {
|
||
List(goals) { goal in
|
||
GoalRowView(goal: goal)
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
}
|
||
.background(Color.tfBackground)
|
||
.navigationTitle(String(localized: "tab.goals"))
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button { showAddSheet = true } label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
.tint(Color.tfPrimary)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showAddSheet) {
|
||
AddGoalSheet()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: – Goal row
|
||
|
||
private struct GoalRowView: View {
|
||
let goal: GoalEntity
|
||
|
||
var body: some View {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(goal.title)
|
||
.foregroundStyle(Color.tfOnBackground)
|
||
HStack(spacing: 4) {
|
||
Text(goal.period.localizedName)
|
||
Text("·")
|
||
Text(targetDescription)
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(Color.tfMuted)
|
||
}
|
||
Spacer()
|
||
Text(goal.isActive
|
||
? String(localized: "goal.active")
|
||
: String(localized: "goal.inactive"))
|
||
.font(.caption)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
goal.isActive
|
||
? Color.tfPrimary.opacity(0.15)
|
||
: Color.tfMuted.opacity(0.15)
|
||
)
|
||
.foregroundStyle(goal.isActive ? Color.tfPrimary : Color.tfMuted)
|
||
.clipShape(Capsule())
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
private var targetDescription: String {
|
||
switch goal.targetType {
|
||
case .duration(let min, let max):
|
||
if let max {
|
||
return "\(min.hhmmss) – \(max.hhmmss)"
|
||
}
|
||
return min.hhmmss
|
||
case .count(let min, let max):
|
||
if let max { return "\(min) – \(max)" }
|
||
return "\(min)"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: – Add goal sheet
|
||
|
||
private struct AddGoalSheet: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@Query(sort: \TaskItem.sortOrder) private var allTasks: [TaskItem]
|
||
@Query(sort: \TagEntity.sortOrder) private var allTags: [TagEntity]
|
||
|
||
@State private var title = ""
|
||
@State private var selectedTaskID: UUID? = nil
|
||
@State private var selectedTagID: UUID? = nil
|
||
@State private var period: GoalPeriod = .daily
|
||
@State private var unit: GoalUnit = .seconds
|
||
@State private var targetMin: Double = 1800
|
||
@State private var hasMax = false
|
||
@State private var targetMaxValue: Double = 3600
|
||
|
||
private var activeTasks: [TaskItem] { allTasks.filter { !$0.isArchived } }
|
||
private var canSave: Bool { !title.trimmingCharacters(in: .whitespaces).isEmpty }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section(String(localized: "goal.form.section.title")) {
|
||
TextField(String(localized: "goal.form.title.placeholder"), text: $title)
|
||
}
|
||
|
||
Section(String(localized: "goal.form.section.scope")) {
|
||
Picker(String(localized: "goal.form.task"), selection: $selectedTaskID) {
|
||
Text(String(localized: "goal.form.none")).tag(Optional<UUID>.none)
|
||
ForEach(activeTasks) { task in
|
||
Label(task.name, systemImage: task.icon).tag(Optional(task.id))
|
||
}
|
||
}
|
||
Picker(String(localized: "goal.form.tag"), selection: $selectedTagID) {
|
||
Text(String(localized: "goal.form.none")).tag(Optional<UUID>.none)
|
||
ForEach(allTags) { tag in
|
||
Text(tag.name).tag(Optional(tag.id))
|
||
}
|
||
}
|
||
}
|
||
|
||
Section(String(localized: "goal.form.section.period")) {
|
||
Picker("", selection: $period) {
|
||
ForEach(GoalPeriod.allCases, id: \.self) { p in
|
||
Text(p.localizedName).tag(p)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
}
|
||
|
||
Section(String(localized: "goal.form.section.target")) {
|
||
Picker("", selection: $unit) {
|
||
Text(String(localized: "goal.unit.duration")).tag(GoalUnit.seconds)
|
||
Text(String(localized: "goal.unit.count")).tag(GoalUnit.count)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.onChange(of: unit) { _, newUnit in
|
||
targetMin = newUnit == .seconds ? 1800 : 1
|
||
targetMaxValue = newUnit == .seconds ? 3600 : 5
|
||
}
|
||
|
||
minValueStepper
|
||
|
||
Toggle(String(localized: "goal.form.has_max"), isOn: $hasMax)
|
||
|
||
if hasMax {
|
||
maxValueStepper
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Toggle(String(localized: "goal.active"), isOn: .constant(true))
|
||
}
|
||
}
|
||
.navigationTitle(String(localized: "goal.add.title"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(String(localized: "action.cancel")) { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(String(localized: "action.save")) { save() }
|
||
.disabled(!canSave)
|
||
.tint(Color.tfPrimary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var minValueStepper: some View {
|
||
if unit == .seconds {
|
||
Stepper(
|
||
value: $targetMin,
|
||
in: 60...86400,
|
||
step: 300
|
||
) {
|
||
Text("\(String(localized: "goal.form.min")): \(targetMin.hhmmss)")
|
||
.font(.subheadline.monospacedDigit())
|
||
}
|
||
} else {
|
||
Stepper(
|
||
value: $targetMin,
|
||
in: 1...9999,
|
||
step: 1
|
||
) {
|
||
Text("\(String(localized: "goal.form.min")): \(Int(targetMin))")
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var maxValueStepper: some View {
|
||
if unit == .seconds {
|
||
Stepper(
|
||
value: $targetMaxValue,
|
||
in: targetMin...86400 * 7,
|
||
step: 300
|
||
) {
|
||
Text("\(String(localized: "goal.form.max")): \(targetMaxValue.hhmmss)")
|
||
.font(.subheadline.monospacedDigit())
|
||
}
|
||
} else {
|
||
Stepper(
|
||
value: $targetMaxValue,
|
||
in: targetMin...9999,
|
||
step: 1
|
||
) {
|
||
Text("\(String(localized: "goal.form.max")): \(Int(targetMaxValue))")
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
let goal = GoalEntity(
|
||
title: title.trimmingCharacters(in: .whitespaces),
|
||
targetValue: targetMin,
|
||
targetMax: hasMax ? targetMaxValue : nil,
|
||
unit: unit,
|
||
period: period
|
||
)
|
||
goal.task = activeTasks.first { $0.id == selectedTaskID }
|
||
goal.tag = allTags.first { $0.id == selectedTagID }
|
||
context.insert(goal)
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
GoalsTabView()
|
||
.modelContainer(for: [GoalEntity.self, TaskItem.self, TagEntity.self], inMemory: true)
|
||
.environment(AppPreferences())
|
||
}
|