Files
InvestmentTrackerApp/PortfolioJournal/Views/Sources/SourceListView.swift
T
alexandrev-tibco 773da6800b Release 1.3.1 (build 20): iCloud sync fix + sources search/filter
iCloud sync:
- Fix: deploy CloudKit schema to production so exports work
- Fix: PredictionCache marked syncable=NO (binary attr caused partial failures)
- Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history
- Fix: auto-deduplication on CloudKit import (processRemoteChanges)
- Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state
- Add: cloudKitForceReload notification so repositories re-fetch on remote changes

Sources UI:
- Add: horizontal category filter chips in SourceListView
- Add: search toggle button with animated TextField

Other:
- Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance)
- Add: fastlane submit lane with run_precheck_before_submit=false
- Update: release notes all locales for 1.3.1
- Update: fastlane API key via pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:19:33 +02:00

447 lines
15 KiB
Swift

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()
@State private var showingSearch = false
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
}
var body: some View {
NavigationStack(path: $navigationPath) {
ZStack {
AppBackground()
Group {
if viewModel.isEmpty {
emptyStateView
} else {
sourcesList
.safeAreaInset(edge: .top, spacing: 0) {
filterBar
}
}
}
}
.navigationTitle("Sources")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.addSourceTapped()
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
showingSearch.toggle()
}
if !showingSearch { viewModel.searchText = "" }
} label: {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.foregroundColor(showingSearch ? .secondary : .primary)
}
}
ToolbarItem(placement: .navigationBarLeading) {
accountFilterMenu
}
}
.sheet(isPresented: $viewModel.showingAddSource) {
AddSourceView()
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
}
.onReceive(accountStore.$selectedAccount) { account in
viewModel.selectedAccount = account
}
.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.")
}
}
}
}
// MARK: - Sources List
private var sourcesList: some View {
List {
// Summary Header
if !viewModel.isFiltered {
Section {
HStack {
VStack(alignment: .leading) {
Text("Total Value")
.font(.caption)
.foregroundColor(.secondary)
Text(viewModel.formattedTotalValue)
.font(.title2.weight(.bold))
}
Spacer()
VStack(alignment: .trailing) {
Text("Sources")
.font(.caption)
.foregroundColor(.secondary)
Text("\(viewModel.sources.count)")
.font(.title2.weight(.bold))
}
}
.padding(.vertical, 4)
}
}
// Source limit warning
if viewModel.sourceLimitReached {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.appWarning)
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
.font(.subheadline)
Spacer()
Button("Upgrade") {
viewModel.showingPaywall = true
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
}
}
}
// Sources
Section {
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 {
HStack {
Text("\(viewModel.sources.count) results")
Spacer()
Button("Clear Filters") {
viewModel.clearFilters()
}
.font(.caption)
}
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.refreshable {
viewModel.loadData()
}
}
// MARK: - Empty State
private var emptyStateView: some View {
VStack(spacing: 20) {
Image(systemName: "tray")
.font(.system(size: 60))
.foregroundColor(.secondary)
Text("No Investment Sources")
.font(.custom("Avenir Next", size: 24).weight(.semibold))
Text("Add your first investment source to start tracking your portfolio.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
viewModel.showingAddSource = true
} label: {
Label("Add Source", systemImage: "plus")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
// MARK: - Filter Bar (chips + search)
private var filterBar: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
CategoryChip(
title: String(localized: "sources_filter_all"),
icon: nil,
isSelected: viewModel.selectedCategory == nil
) {
viewModel.selectCategory(nil)
}
ForEach(viewModel.categories) { category in
CategoryChip(
title: category.name,
icon: category.icon,
isSelected: viewModel.selectedCategory?.id == category.id
) {
viewModel.selectCategory(category)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search sources", text: $viewModel.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(Color(.systemGray6))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.bottom, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
Divider()
}
.background(.bar)
}
// MARK: - Account Filter Menu
private var accountFilterMenu: some View {
Menu {
Button {
accountStore.selectAllAccounts()
} label: {
HStack {
Text("All Accounts")
if accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
Divider()
ForEach(availableAccounts, id: \.objectID) { account in
Button {
accountStore.selectAccount(account)
} label: {
HStack {
Text(account.name)
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "person.2")
if !accountStore.showAllAccounts {
Circle()
.fill(Color.appPrimary)
.frame(width: 8, height: 8)
}
}
}
}
private var availableAccounts: [Account] {
accountStore.accounts.filter { $0.safeId != nil }
}
}
// MARK: - Category Chip
private struct CategoryChip: View {
let title: String
let icon: String?
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
if let icon {
Image(systemName: icon)
.font(.caption2)
}
Text(title)
.font(.subheadline)
.fontWeight(isSelected ? .semibold : .regular)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.appPrimary : Color(.systemGray5))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(20)
}
.buttonStyle(.plain)
}
}
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 {
@ObservedObject var source: InvestmentSource
let calmModeEnabled: Bool
var body: some View {
HStack(spacing: 12) {
// Category indicator
ZStack {
Circle()
.fill((source.category?.color ?? .gray).opacity(0.2))
.frame(width: 44, height: 44)
Image(systemName: source.category?.icon ?? "questionmark")
.font(.system(size: 18))
.foregroundColor(source.category?.color ?? .gray)
}
// Source info
VStack(alignment: .leading, spacing: 4) {
Text(source.name)
.font(.headline)
HStack(spacing: 8) {
Text(source.category?.name ?? "Uncategorized")
.font(.caption)
.foregroundColor(.secondary)
if source.needsUpdate {
Label("Needs update", systemImage: "bell.badge.fill")
.font(.caption2)
.foregroundColor(.appWarning)
}
}
}
Spacer()
// Value
VStack(alignment: .trailing, spacing: 4) {
Text(source.latestValue.compactCurrencyString)
.font(.subheadline.weight(.semibold))
if calmModeEnabled {
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
.font(.caption)
.foregroundColor(.secondary)
} else {
HStack(spacing: 2) {
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
.font(.caption)
}
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
}
}
}
.padding(.vertical, 4)
.opacity(source.isActive ? 1 : 0.5)
}
}
#Preview {
SourceListView(iapService: IAPService())
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}