Files
InvestmentTrackerApp/PortfolioJournal/Views/Sources/SourceListView.swift
T
alexandrev-tibco 319ab9589e Columna lateral de 300 pt para que iOS 27 no la esconda en iPad mini
En iOS 27, NavigationSplitView(.balanced) con la barra lateral en ideal 340 pt
deja la columna fuera de pantalla en regular estrecho (iPad mini vertical,
744 pt): la app abre solo el detalle ("Select a Month" / "Select a Source")
y el recorrido DuoLayoutUITests fallaba al no encontrar el mes. En 26.5 se
mostraban las dos columnas. Con min 280 / ideal 300 el sistema vuelve a
acoplar la lista junto al detalle.

Verificado en iOS 27.0: DuoLayoutUITests pasa en iPad mini (27) y en
iPhone 17 Pro (27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-17 13:48:36 +02:00

498 lines
17 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
@State private var sourceToDelete: InvestmentSource?
@State private var showingSearch = false
@State private var selectedSourceID: NSManagedObjectID?
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
}
/// Sidebar/detail split: two columns in regular width (iPad, iPhone Duo
/// inner screen), a single collapsing stack in compact width. Selection is
/// the only navigation state, so it survives the size-class change when the
/// Duo opens or closes.
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
ZStack {
AppBackground()
if viewModel.isEmpty { emptyStateView } else { sourcesList }
}
.navigationTitle("Sources")
.toolbar { sourcesToolbar }
.sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() }
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.onAppear { syncAccountState() }
.onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 }
.onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 }
.confirmationDialog(
"Delete Source",
isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }),
titleVisibility: .visible
) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage }
.navigationSplitViewColumnWidth(min: 280, ideal: 300, max: 420)
} detail: {
NavigationStack {
detailContent
}
}
.navigationSplitViewStyle(.balanced)
}
@ViewBuilder
private var detailContent: some View {
if let id = selectedSourceID {
if let source = try? context.existingObject(with: id) as? InvestmentSource,
!source.isDeleted {
SourceDetailView(source: source, iapService: iapService)
.id(id)
} else {
MissingSourceView()
}
} else {
noSourceSelectedView
}
}
// MARK: - Shared Helpers
private func syncAccountState() {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
}
@ToolbarContentBuilder
private var sourcesToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button { viewModel.addSourceTapped() } label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { showingSearch.toggle() }
if !showingSearch { viewModel.searchText = "" }
} label: {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.foregroundStyle(showingSearch ? .secondary : .primary)
}
}
ToolbarItem(placement: .topBarLeading) {
accountFilterMenu
}
}
@ViewBuilder
private var deleteSourceDialogButtons: some View {
Button("Delete", role: .destructive) {
if let source = sourceToDelete {
if selectedSourceID == source.objectID { selectedSourceID = nil }
viewModel.deleteSource(source)
sourceToDelete = nil
}
}
Button("Cancel", role: .cancel) { sourceToDelete = nil }
}
@ViewBuilder
private var deleteSourceDialogMessage: some View {
if let source = sourceToDelete {
Text("This will permanently delete \(source.name) and all its snapshots.")
}
}
private var noSourceSelectedView: some View {
ZStack {
AppBackground()
VStack(spacing: 32) {
// Icon badge con gradiente
ZStack {
Circle()
.fill(LinearGradient.appPrimaryGradient)
.frame(width: 120, height: 120)
.shadow(color: Color.appPrimary.opacity(0.25), radius: 28, y: 10)
Circle()
.fill(Color.white.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 50, weight: .light))
.foregroundStyle(.white)
}
VStack(spacing: 10) {
Text("Select a Source")
.font(.title2.weight(.semibold))
Text("Pick an investment source from the\npanel on the left to see its details.")
.frame(maxWidth: 360)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Call-to-action pill
Label("Select from the list", systemImage: "arrow.left")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 22)
.padding(.vertical, 11)
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.appPrimary.opacity(0.2), lineWidth: 1))
}
.padding(48)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Sources List
private var sourcesList: some View {
List(selection: $selectedSourceID) {
// Filter Bar
Section {
filterBarContent
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listSectionSeparator(.hidden)
// Summary Header
if !viewModel.isFiltered {
Section {
HStack {
VStack(alignment: .leading) {
Text("Total Value")
.font(.caption)
.foregroundStyle(.secondary)
Text(viewModel.formattedTotalValue)
.font(.title2.weight(.bold))
.hiddenBalance()
}
Spacer()
VStack(alignment: .trailing) {
Text("Sources")
.font(.caption)
.foregroundStyle(.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")
.foregroundStyle(Color.appWarning)
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
.font(.subheadline)
Spacer()
Button("Upgrade") {
viewModel.showingPaywall = true
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.appPrimary)
}
}
}
// Sources
Section {
ForEach(viewModel.sources, id: \.objectID) { source in
SourceRowView(source: source)
.tag(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))
.foregroundStyle(.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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
viewModel.showingAddSource = true
} label: {
Label("Add Source", systemImage: "plus")
.font(.headline)
.foregroundStyle(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
}
}
// MARK: - Filter Bar (chips + search)
private var filterBarContent: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
CategoryChip(
title: String(localized: "sources_filter_all"),
icon: nil,
isSelected: viewModel.selectedCategoryIds.isEmpty
) {
viewModel.selectCategory(nil)
}
ForEach(viewModel.categories) { category in
CategoryChip(
title: category.name,
icon: category.icon,
isSelected: viewModel.selectedCategoryIds.contains(category.id)
) {
viewModel.selectCategory(category)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search sources", text: $viewModel.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.bottom, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
// 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))
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.plain)
}
}
private struct MissingSourceView: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 42))
.foregroundStyle(.secondary)
Text("Source not available")
.font(.headline)
Text("This source was deleted.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
}
// MARK: - Source Row View
struct SourceRowView: View {
@ObservedObject var source: InvestmentSource
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))
.foregroundStyle(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)
.foregroundStyle(.secondary)
if source.needsUpdate {
Label("Needs update", systemImage: "clock.fill")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.appWarning)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appWarning.opacity(0.12))
.clipShape(Capsule())
}
}
}
Spacer()
// Value
VStack(alignment: .trailing, spacing: 4) {
Text(source.latestValue.compactCurrencyString)
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.hiddenBalance()
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)
}
.foregroundStyle(source.totalReturn >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.padding(.vertical, 4)
.opacity(source.isActive ? 1 : 0.5)
}
}
#Preview {
SourceListView(iapService: IAPService())
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}