import SwiftUI struct LeadOverviewView: View { let session: LoginSession let onLogout: () -> Void private let repository: any LeadRepository private let detailRepository: any LeadDetailRepository @State private var model: LeadOverviewViewModel @State private var refreshID = 0 @FocusState private var isSearchFocused: Bool @State private var searchFieldFrame: CGRect = .zero @State private var isPulledDown = false @Namespace private var cardMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var pendingBookmark: Lead? @State private var flight: LeadCardFlight? @State private var flightShowsBookmark = false @State private var selectedLead: Lead? @State private var isShowingDetail = false @Environment(\.dynamicTypeSize) private var dynamicTypeSize init(session: LoginSession, repository: any LeadRepository, detailRepository: (any LeadDetailRepository)? = nil, onLogout: @escaping () -> Void) { self.detailRepository = detailRepository ?? DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService()) self.repository = repository self.session = session self.onLogout = onLogout _model = State(initialValue: LeadOverviewViewModel(salesCode: session.salesCode, repository: repository)) } var body: some View { @Bindable var model = model GeometryReader { geometry in let columnCount = dynamicTypeSize.isAccessibilitySize ? 1 : min(3, max(1, Int((geometry.size.width - 48) / 310))) let columns = Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: columnCount) List { VStack(alignment: .leading, spacing: 24) { LeadOverviewHeaderView(session: session, lastUpdated: model.lastUpdated, isLoading: model.isLoading, onRefresh: refresh, onLogout: onLogout) LeadSearchField(text: $model.searchText, isFocused: $isSearchFocused, onClear: model.clearSearchText) .onGeometryChange(for: CGRect.self) { geometry in geometry.frame(in: .global) } action: { frame in searchFieldFrame = frame } if let error = model.actionError { LeadErrorBanner(error: error, onRetry: { model.dismissActionError() refresh() }, onLogin: onLogout) } if model.hasCurrentResults && !model.isSessionExpired { if let error = model.error { LeadErrorBanner(error: error, onRetry: refresh, onLogin: onLogout) } BookmarkedLeadsSection(leads: model.bookmarkedLeads, columns: columns, flyingLeadID: flight?.lead.id, cardMotion: cardMotion, isBusy: pendingBookmark != nil, onOpen: open, onBookmark: toggleBookmark) .zIndex(1) LeadResultsSection(leads: model.leads, columns: columns, selection: $model.selection, cardMotion: cardMotion, isBusy: pendingBookmark != nil, onOpen: open, onBookmark: toggleBookmark) if model.leads.isEmpty { LeadOverviewStateView(error: nil, isLoading: false, isSearching: !model.query.searchText.isEmpty || model.selection == .open, onRetry: refresh, onReset: model.resetSearch, onLogin: onLogout) } } else { LeadOverviewStateView(error: model.error ?? model.actionError, isLoading: model.isLoading || (model.error == nil && model.actionError == nil), isSearching: !model.query.searchText.isEmpty || model.selection == .open, onRetry: refresh, onReset: model.resetSearch, onLogin: onLogout) } } .padding(24) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) .listRowBackground(Color.clear) } .listStyle(.plain) .scrollContentBackground(.hidden) .scrollBounceBehavior(.always, axes: .vertical) .refreshable { [model] in guard !model.isSessionExpired else { return } await model.reload() } .onScrollGeometryChange(for: Bool.self) { geometry in geometry.contentOffset.y + geometry.contentInsets.top < -4 } action: { _, pulledDown in isPulledDown = pulledDown } .overlay(alignment: .top) { if model.isLoading && model.hasCurrentResults && !isPulledDown { ProgressView() .accessibilityLabel("Interessenten werden aktualisiert") .padding(12) .background(.regularMaterial, in: .circle) .padding(.top, 4) .allowsHitTesting(false) } } .scrollDisabled(pendingBookmark != nil) .overlay(alignment: .topLeading) { if let flight { LeadCardFlightView(lead: flight.lead, showsBookmark: flightShowsBookmark) .matchedGeometryEffect(id: "flight-\(flight.lead.id)", in: cardMotion, properties: .frame, isSource: false) .allowsHitTesting(false) .accessibilityHidden(true) } } .scrollDismissesKeyboard(.interactively) .background(LeadOverviewStyle.background.ignoresSafeArea()) } .simultaneousGesture( SpatialTapGesture(coordinateSpace: .global) .onEnded { value in if !searchFieldFrame.contains(value.location) { isSearchFocused = false } } ) .tint(LeadOverviewStyle.blue) .navigationTitle("Interessenten") .toolbar(.hidden, for: .navigationBar) .task(id: model.query) { await model.reload(debounce: true, useCachedList: true) } .task(id: refreshID) { guard refreshID > 0 else { return } await model.reload() } .task(id: pendingBookmark?.id) { guard let lead = pendingBookmark else { return } guard let updated = await model.saveBookmark(lead), !Task.isCancelled else { pendingBookmark = nil return } if reduceMotion { withAnimation(.easeInOut(duration: 0.2)) { model.applyBookmark(updated) } pendingBookmark = nil return } // Establish a copy at the unchanged source before the single layout transaction. flightShowsBookmark = lead.isBookmarked flight = LeadCardFlight(lead: lead) try? await Task.sleep(for: .milliseconds(20)) guard !Task.isCancelled else { flight = nil pendingBookmark = nil return } // Unlock at the animation's logical end, without waiting for the spring's settling tail. withAnimation(.smooth(duration: 0.55), completionCriteria: .logicallyComplete) { model.applyBookmark(updated) flightShowsBookmark = updated.isBookmarked } completion: { flight = nil pendingBookmark = nil } } .navigationDestination(isPresented: $isShowingDetail) { if let selectedLead { LeadDetailScreen(leadID: selectedLead.id, salesCode: session.salesCode, repository: detailRepository, onLogout: onLogout) .id(selectedLead.id) .toolbar(.visible, for: .navigationBar) } } } private func refresh() { refreshID += 1 } private func open(_ lead: Lead) { guard pendingBookmark == nil, !isShowingDetail, !model.isSessionExpired else { return } selectedLead = lead isShowingDetail = true } private func toggleBookmark(_ lead: Lead) { guard pendingBookmark == nil, !model.isSessionExpired else { return } pendingBookmark = lead } } #Preview("Übersicht", traits: .fixedLayout(width: 1194, height: 834)) { NavigationStack { LeadOverviewView( session: LoginSession(salesCode: "MKR", displayName: "M. Krämer"), repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)), onLogout: {} ) } } #Preview("Keine Interessenten", traits: .fixedLayout(width: 1194, height: 834)) { NavigationStack { LeadOverviewView( session: LoginSession(salesCode: "MKR", displayName: "M. Krämer"), repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero, records: [])), onLogout: {} ) } } #Preview("Serverfehler", traits: .fixedLayout(width: 1194, height: 834)) { NavigationStack { LeadOverviewView( session: LoginSession(salesCode: "MKR"), repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: .http(500), delay: .zero)), onLogout: {} ) } }