224 lines
10 KiB
Swift
224 lines
10 KiB
Swift
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: {}
|
|
)
|
|
}
|
|
}
|