Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
223
GasTankAR/Views/Leads/Overview/LeadOverviewView.swift
Normal file
223
GasTankAR/Views/Leads/Overview/LeadOverviewView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user