initial version
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var targetAmount = ""
|
||||
@State private var targetDate = Date()
|
||||
@State private var includeTargetDate = false
|
||||
@State private var didLoadGoal = false
|
||||
|
||||
let account: Account?
|
||||
let goal: Goal?
|
||||
private let goalRepository = GoalRepository()
|
||||
|
||||
init(account: Account?, goal: Goal? = nil) {
|
||||
self.account = account
|
||||
self.goal = goal
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Goal name", text: $name)
|
||||
TextField("Target amount", text: $targetAmount)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
Toggle("Add target date", isOn: $includeTargetDate)
|
||||
if includeTargetDate {
|
||||
DatePicker("Target date", selection: $targetDate, displayedComponents: .date)
|
||||
.datePickerStyle(.graphical)
|
||||
}
|
||||
} header: {
|
||||
Text("Goal Details")
|
||||
}
|
||||
}
|
||||
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { saveGoal() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.onAppear {
|
||||
guard let goal, !didLoadGoal else { return }
|
||||
name = goal.name ?? ""
|
||||
if let amount = goal.targetAmount?.decimalValue {
|
||||
targetAmount = NSDecimalNumber(decimal: amount).stringValue
|
||||
}
|
||||
if let target = goal.targetDate {
|
||||
includeTargetDate = true
|
||||
targetDate = target
|
||||
}
|
||||
didLoadGoal = true
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
|
||||
}
|
||||
|
||||
private func saveGoal() {
|
||||
guard let value = parseDecimal(targetAmount) else { return }
|
||||
if let goal {
|
||||
goalRepository.updateGoal(
|
||||
goal,
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
clearTargetDate: !includeTargetDate
|
||||
)
|
||||
} else {
|
||||
goalRepository.createGoal(
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
account: account
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalEditorView(account: nil)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalProgressBar: View {
|
||||
let progress: Double
|
||||
var tint: Color = .appSecondary
|
||||
var background: Color = Color.gray.opacity(0.15)
|
||||
var iconName: String = "flag.checkered"
|
||||
var iconColor: Color = .appSecondary
|
||||
|
||||
private var clampedProgress: CGFloat {
|
||||
CGFloat(min(max(progress, 0), 1))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let iconOffset = max(0, width * clampedProgress - 10)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(background)
|
||||
Capsule()
|
||||
.fill(tint)
|
||||
.frame(width: width * clampedProgress)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
Image(systemName: iconName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(iconColor)
|
||||
.offset(x: iconOffset)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.accessibilityLabel("Goal progress")
|
||||
.accessibilityValue("\(Int(progress * 100)) percent")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalProgressBar(progress: 0.45)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalShareCardView: View {
|
||||
let name: String
|
||||
let progress: Double
|
||||
let currentValue: Decimal
|
||||
let targetValue: Decimal
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Goal Progress")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Text(name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "sparkles")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
progress: progress,
|
||||
tint: .white.opacity(0.9),
|
||||
background: .white.opacity(0.2),
|
||||
iconName: "sparkles",
|
||||
iconColor: .white
|
||||
)
|
||||
.frame(height: 10)
|
||||
|
||||
HStack {
|
||||
Text(currentValue.currencyString)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("of \(targetValue.currencyString)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.right")
|
||||
Text("Track yours in Portfolio Journal")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 320, height: 220)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalShareCardView(
|
||||
name: "1M Goal",
|
||||
progress: 0.42,
|
||||
currentValue: 420_000,
|
||||
targetValue: 1_000_000
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalsView: View {
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
Section {
|
||||
ForEach(viewModel.goals) { goal in
|
||||
GoalRowView(
|
||||
goal: goal,
|
||||
progress: viewModel.progress(for: goal),
|
||||
totalValue: viewModel.totalValue(for: goal),
|
||||
paceStatus: viewModel.paceStatus(for: goal)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingGoal = goal
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteGoal(goal)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
editingGoal = goal
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.appSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddGoal) {
|
||||
GoalEditorView(account: accountStore.showAllAccounts ? nil : accountStore.selectedAccount)
|
||||
}
|
||||
.sheet(item: $editingGoal) { goal in
|
||||
GoalEditorView(account: goal.account, goal: goal)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalRowView: View {
|
||||
let goal: Goal
|
||||
let progress: Double
|
||||
let totalValue: Decimal
|
||||
let paceStatus: GoalPaceStatus?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
progress: progress,
|
||||
currentValue: totalValue,
|
||||
targetValue: goal.targetDecimal
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalsView()
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
Reference in New Issue
Block a user