mycode/myApp/TallyFlow/IOS/Features/Goals/GoalsTabView.swift
songyc macbook b209199c2d feat: Setup app shell, SwiftData models, and real-time tracking engine
- 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
2026-06-26 03:32:29 +09:00

249 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}