mycode/IOS/Views/GoalView.swift
2026-06-18 22:16:28 +09:00

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