Add lead workflows, PDF signing, AR projects and photo gallery integration

This commit is contained in:
Dennis Nemec
2026-11-08 17:49:39 +01:00
parent a343bde741
commit 8d18873412
82 changed files with 6401 additions and 34 deletions

View File

@ -0,0 +1,223 @@
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: {}
)
}
}