Base fixes and test harness
This commit is contained in:
@@ -185,18 +185,27 @@ struct AddSourceView: View {
|
||||
}
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
let locale = CurrencyFormatter.locale(for: currencyCode)
|
||||
let cleaned = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
formatter.locale = locale
|
||||
|
||||
return formatter.number(from: cleaned)?.decimalValue
|
||||
if let value = formatter.number(from: cleaned)?.decimalValue {
|
||||
return value
|
||||
}
|
||||
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
private var currencySymbol: String {
|
||||
@@ -206,6 +215,13 @@ struct AddSourceView: View {
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
}
|
||||
|
||||
private var currencyCode: String {
|
||||
if let account = selectedAccount, let code = account.currencyCode {
|
||||
return code
|
||||
}
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return availableAccounts.first { $0.safeId == id }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import Charts
|
||||
|
||||
struct SourceDetailView: View {
|
||||
@@ -7,7 +8,8 @@ struct SourceDetailView: View {
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@State private var editingSnapshotSelection: SnapshotSelection?
|
||||
|
||||
init(source: InvestmentSource, iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
|
||||
@@ -17,6 +19,19 @@ struct SourceDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isDeleted {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
contentView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contentView: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
@@ -49,7 +64,7 @@ struct SourceDetailView: View {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.source.name)
|
||||
.navigationTitle(viewModel.safeSourceName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -70,12 +85,16 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
||||
AddSnapshotView(source: viewModel.source)
|
||||
}
|
||||
.sheet(item: $editingSnapshot) { snapshot in
|
||||
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
||||
AddSnapshotView(source: viewModel.source)
|
||||
}
|
||||
.sheet(item: $editingSnapshotSelection) { selection in
|
||||
if let snapshot = try? viewContext.existingObject(with: selection.id) as? Snapshot {
|
||||
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
|
||||
} else {
|
||||
MissingSnapshotView()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddTransaction) {
|
||||
AddTransactionView { type, date, shares, price, amount, notes in
|
||||
viewModel.addTransaction(
|
||||
@@ -100,13 +119,16 @@ struct SourceDetailView: View {
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
// Delete and dismiss
|
||||
let repository = InvestmentSourceRepository()
|
||||
repository.deleteSource(viewModel.source)
|
||||
viewModel.deleteSource()
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
|
||||
Text("This will permanently delete \(viewModel.safeSourceName) and all its snapshots.")
|
||||
}
|
||||
.onChange(of: viewModel.isDeleted) { _, deleted in
|
||||
if deleted {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,25 +448,25 @@ struct SourceDetailView: View {
|
||||
|
||||
// Use LazyVStack for better performance with many snapshots
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||
ForEach(viewModel.visibleSnapshots, id: \.objectID) { snapshot in
|
||||
VStack(spacing: 0) {
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
@@ -456,7 +478,7 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.visibleSnapshots.last?.id {
|
||||
if snapshot.objectID != viewModel.visibleSnapshots.last?.objectID {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
@@ -505,7 +527,7 @@ struct SnapshotRowView: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
Text(snapshot.safeDate.friendlyDescription)
|
||||
.font(.subheadline)
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
@@ -591,6 +613,27 @@ struct MetricChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SnapshotSelection: Identifiable {
|
||||
let id: NSManagedObjectID
|
||||
}
|
||||
|
||||
private struct MissingSnapshotView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Snapshot not available")
|
||||
.font(.headline)
|
||||
Text("This snapshot was removed or is no longer accessible.")
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
Text("Source Detail Preview")
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SourceListView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
@StateObject private var viewModel: SourceListViewModel
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@State private var sourceToDelete: InvestmentSource?
|
||||
@State private var navigationPath = NavigationPath()
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
@@ -58,6 +62,36 @@ struct SourceListView: View {
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
}
|
||||
.navigationDestination(for: NSManagedObjectID.self) { objectID in
|
||||
if let source = try? context.existingObject(with: objectID) as? InvestmentSource,
|
||||
!source.isDeleted {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} else {
|
||||
MissingSourceView()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Source",
|
||||
isPresented: Binding(
|
||||
get: { sourceToDelete != nil },
|
||||
set: { if !$0 { sourceToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let source = sourceToDelete {
|
||||
viewModel.deleteSource(source)
|
||||
sourceToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
sourceToDelete = nil
|
||||
}
|
||||
} message: {
|
||||
if let source = sourceToDelete {
|
||||
Text("This will permanently delete \(source.name) and all its snapshots.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,15 +148,19 @@ struct SourceListView: View {
|
||||
|
||||
// Sources
|
||||
Section {
|
||||
ForEach(viewModel.sources) { source in
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} label: {
|
||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
viewModel.deleteSource(at: indexSet)
|
||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
navigationPath.append(source.objectID)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
sourceToDelete = source
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if viewModel.isFiltered {
|
||||
@@ -263,6 +301,22 @@ struct SourceListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MissingSourceView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 42))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Source not available")
|
||||
.font(.headline)
|
||||
Text("This source was deleted.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Row View
|
||||
|
||||
struct SourceRowView: View {
|
||||
|
||||
Reference in New Issue
Block a user