initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -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()))
}