307 lines
11 KiB
Swift
307 lines
11 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
struct GoalView: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@State private var viewModel = GoalViewModel()
|
|
@State private var showAddSheet = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Theme.background.ignoresSafeArea()
|
|
Group {
|
|
if viewModel.goals.isEmpty {
|
|
emptyState
|
|
} else {
|
|
goalContent
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "tab.goals"))
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button { showAddSheet = true } label: {
|
|
Image(systemName: "plus")
|
|
.foregroundStyle(Theme.green)
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showAddSheet) {
|
|
AddGoalSheet { targetType, conditions, frequency, task, category in
|
|
viewModel.addGoal(targetType: targetType, conditions: conditions, frequency: frequency, task: task, category: category)
|
|
}
|
|
}
|
|
.onAppear { viewModel.setup(context: modelContext) }
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "flag.badge.ellipsis")
|
|
.font(.system(size: 56))
|
|
.foregroundStyle(Theme.green.opacity(0.6))
|
|
Text(String(localized: "goal.empty"))
|
|
.foregroundStyle(Theme.primaryText.opacity(0.6))
|
|
}
|
|
}
|
|
|
|
private var goalContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
progressChart
|
|
.padding(.horizontal)
|
|
goalCards
|
|
.padding(.horizontal)
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
}
|
|
|
|
// MARK: - Chart
|
|
|
|
private var progressChart: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(String(localized: "goal.chart.title"))
|
|
.font(.headline)
|
|
.foregroundStyle(Theme.primaryText)
|
|
|
|
Chart(viewModel.goalProgress) { progress in
|
|
BarMark(
|
|
x: .value(String(localized: "goal.progress"), progress.ratio * 100),
|
|
y: .value("", progress.displayName)
|
|
)
|
|
.foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow)
|
|
.cornerRadius(4)
|
|
}
|
|
.chartXScale(domain: 0.0...100.0)
|
|
.chartXAxis {
|
|
AxisMarks(values: [0.0, 25.0, 50.0, 75.0, 100.0]) { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let v = value.as(Double.self) {
|
|
Text("\(Int(v))%")
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.primaryText.opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks { _ in
|
|
AxisValueLabel()
|
|
}
|
|
}
|
|
.frame(height: CGFloat(viewModel.goalProgress.count) * 48 + 20)
|
|
}
|
|
.padding()
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
// MARK: - Goal Cards
|
|
|
|
private var goalCards: some View {
|
|
VStack(spacing: 12) {
|
|
ForEach(viewModel.goalProgress) { progress in
|
|
GoalCard(progress: progress) {
|
|
viewModel.deleteGoal(progress.goal)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Goal Card
|
|
|
|
private struct GoalCard: View {
|
|
let progress: GoalProgress
|
|
let onDelete: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(progress.displayName)
|
|
.font(.body)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(Theme.primaryText)
|
|
Text(subtitleText)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.primaryText.opacity(0.5))
|
|
}
|
|
Spacer()
|
|
Button(role: .destructive, action: onDelete) {
|
|
Image(systemName: "trash")
|
|
.font(.caption)
|
|
.foregroundStyle(.red.opacity(0.7))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
ProgressView(value: progress.ratio)
|
|
.tint(progress.isCompleted ? Theme.green : Theme.yellow)
|
|
|
|
HStack {
|
|
Text(currentText)
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.primaryText.opacity(0.6))
|
|
Spacer()
|
|
Text(progress.percentText)
|
|
.font(.caption)
|
|
.fontWeight(.bold)
|
|
.foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private var subtitleText: String {
|
|
let cond = progress.goal.targetType == "count"
|
|
? String(localized: "goal.condition.count")
|
|
: String(localized: "goal.condition.time")
|
|
let freq: String
|
|
switch progress.goal.frequency {
|
|
case .daily: freq = String(localized: "goal.frequency.daily")
|
|
case .weekly: freq = String(localized: "goal.frequency.weekly")
|
|
case .monthly: freq = String(localized: "goal.frequency.monthly")
|
|
}
|
|
return "\(cond) · \(freq)"
|
|
}
|
|
|
|
private var currentText: String {
|
|
if progress.goal.targetType == "count" {
|
|
return "\(Int(progress.current)) / \(Int(progress.target)) \(String(localized: "goal.condition.count"))"
|
|
} else {
|
|
let currentMins = Int(progress.current) / 60
|
|
let targetMins = Int(progress.target) / 60
|
|
return "\(currentMins)m / \(targetMins)m"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Goal Sheet
|
|
|
|
private struct AddGoalSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Query(sort: \TaskItem.name) private var tasks: [TaskItem]
|
|
@Query(sort: \Category.name) private var categories: [Category]
|
|
|
|
@State private var targetType: GoalTargetType = .task
|
|
@State private var selectedTask: TaskItem?
|
|
@State private var selectedCategory: Category?
|
|
@State private var conditionType: String = "count"
|
|
@State private var threshold: Double = 5
|
|
@State private var frequency: GoalFrequency = .daily
|
|
|
|
let onAdd: (String, Double, GoalFrequency, TaskItem?, Category?) -> Void
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section(header: Text(String(localized: "goal.target"))) {
|
|
Picker(String(localized: "goal.target"), selection: $targetType) {
|
|
Text(String(localized: "goal.target.task")).tag(GoalTargetType.task)
|
|
Text(String(localized: "goal.target.category")).tag(GoalTargetType.category)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: targetType) { _, _ in
|
|
selectedTask = nil
|
|
selectedCategory = nil
|
|
}
|
|
|
|
if targetType == .task {
|
|
Picker(String(localized: "goal.target.task"), selection: $selectedTask) {
|
|
Text(String(localized: "goal.no.target")).tag(Optional<TaskItem>.none)
|
|
ForEach(tasks) { task in
|
|
Text("\(task.icon) \(task.name)").tag(Optional(task))
|
|
}
|
|
}
|
|
} else {
|
|
Picker(String(localized: "goal.target.category"), selection: $selectedCategory) {
|
|
Text(String(localized: "goal.no.target")).tag(Optional<Category>.none)
|
|
ForEach(categories) { cat in
|
|
Text(cat.name).tag(Optional(cat))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Section(header: Text(String(localized: "goal.condition"))) {
|
|
Picker(String(localized: "goal.condition"), selection: $conditionType) {
|
|
Text(String(localized: "goal.condition.count")).tag("count")
|
|
Text(String(localized: "goal.condition.time")).tag("duration")
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: conditionType) { _, _ in threshold = 5 }
|
|
}
|
|
|
|
Section(header: Text(String(localized: "goal.threshold"))) {
|
|
HStack {
|
|
Slider(
|
|
value: $threshold,
|
|
in: conditionType == "count" ? 1.0...100.0 : 5.0...240.0,
|
|
step: conditionType == "count" ? 1.0 : 5.0
|
|
)
|
|
.tint(Theme.green)
|
|
Text(thresholdText)
|
|
.font(.body)
|
|
.monospacedDigit()
|
|
.foregroundStyle(Theme.green)
|
|
.frame(width: 52, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
Section(header: Text(String(localized: "goal.frequency"))) {
|
|
Picker(String(localized: "goal.frequency"), selection: $frequency) {
|
|
Text(String(localized: "goal.frequency.daily")).tag(GoalFrequency.daily)
|
|
Text(String(localized: "goal.frequency.weekly")).tag(GoalFrequency.weekly)
|
|
Text(String(localized: "goal.frequency.monthly")).tag(GoalFrequency.monthly)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "goal.add"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "button.cancel")) { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "button.add")) {
|
|
let storedConditions = conditionType == "duration" ? threshold * 60.0 : threshold
|
|
onAdd(
|
|
conditionType,
|
|
storedConditions,
|
|
frequency,
|
|
targetType == .task ? selectedTask : nil,
|
|
targetType == .category ? selectedCategory : nil
|
|
)
|
|
dismiss()
|
|
}
|
|
.disabled(!canAdd)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var thresholdText: String {
|
|
if conditionType == "count" {
|
|
return "\(Int(threshold))"
|
|
} else {
|
|
let h = Int(threshold) / 60
|
|
let m = Int(threshold) % 60
|
|
return h > 0 ? "\(h)h\(m)m" : "\(Int(threshold))m"
|
|
}
|
|
}
|
|
|
|
private var canAdd: Bool {
|
|
targetType == .task ? selectedTask != nil : selectedCategory != nil
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
GoalView()
|
|
}
|
|
}
|