Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
50
GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift
Normal file
50
GasTankAR/Views/Leads/Overview/BookmarkedLeadCardView.swift
Normal file
@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarkedLeadCardView: View {
|
||||
let lead: Lead
|
||||
let isBusy: Bool
|
||||
let onOpen: () -> Void
|
||||
let onBookmark: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Button(action: onOpen) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(lead.id)
|
||||
.font(.caption)
|
||||
.padding(.trailing, 36)
|
||||
Text(lead.displayName)
|
||||
.font(.headline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(lead.locality)
|
||||
.font(.subheadline)
|
||||
Label(lead.phoneNumber ?? " ", systemImage: "phone.fill")
|
||||
.font(.subheadline)
|
||||
.hidden()
|
||||
.accessibilityHidden(true)
|
||||
HStack(spacing: 12) {
|
||||
LeadStatusView(offerCount: lead.offerCount,
|
||||
unsignedContractCount: lead.unsignedContractCount,
|
||||
highlighted: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.foregroundStyle(.white)
|
||||
.background(LeadOverviewStyle.blue, in: .rect(cornerRadius: 18))
|
||||
.shadow(color: LeadOverviewStyle.blue.opacity(0.18), radius: 8, y: 4)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Interessentendetails öffnen")
|
||||
|
||||
LeadBookmarkButton(isBookmarked: true, isBusy: isBusy, action: onBookmark)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift
Normal file
37
GasTankAR/Views/Leads/Overview/BookmarkedLeadsSection.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarkedLeadsSection: View {
|
||||
let leads: [Lead]
|
||||
let columns: [GridItem]
|
||||
let flyingLeadID: String?
|
||||
let cardMotion: Namespace.ID
|
||||
let isBusy: Bool
|
||||
let onOpen: (Lead) -> Void
|
||||
let onBookmark: (Lead) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("Gemerkt").font(.title2.bold())
|
||||
Text(leads.count, format: .number)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if leads.isEmpty {
|
||||
Text("Mit dem Lesezeichen kannst du dir Interessenten merken.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
LeadCardGrid(columnCount: columns.count) {
|
||||
ForEach(leads) { lead in
|
||||
BookmarkedLeadCardView(lead: lead, isBusy: isBusy,
|
||||
onOpen: { onOpen(lead) },
|
||||
onBookmark: { onBookmark(lead) })
|
||||
.matchedGeometryEffect(id: "flight-\(lead.id)", in: cardMotion)
|
||||
.opacity(flyingLeadID == lead.id ? 0 : 1)
|
||||
.transition(.identity)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift
Normal file
23
GasTankAR/Views/Leads/Overview/LeadCardFlightView.swift
Normal file
@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadCardFlight {
|
||||
let lead: Lead
|
||||
}
|
||||
|
||||
/// The moving copy lives above the List so row layout and clipping cannot interrupt its path.
|
||||
struct LeadCardFlightView: View {
|
||||
let lead: Lead
|
||||
let showsBookmark: Bool
|
||||
|
||||
var body: some View {
|
||||
var unbookmarkedLead = lead
|
||||
unbookmarkedLead.isBookmarked = false
|
||||
return ZStack(alignment: .top) {
|
||||
LeadCardView(lead: unbookmarkedLead, isBusy: false, onOpen: {}, onBookmark: {})
|
||||
.opacity(showsBookmark ? 0 : 1)
|
||||
BookmarkedLeadCardView(lead: lead, isBusy: false, onOpen: {}, onBookmark: {})
|
||||
.opacity(showsBookmark ? 1 : 0)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.12), radius: 14, y: 8)
|
||||
}
|
||||
}
|
||||
44
GasTankAR/Views/Leads/Overview/LeadCardGrid.swift
Normal file
44
GasTankAR/Views/Leads/Overview/LeadCardGrid.swift
Normal file
@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
/// All card positions are calculated in the same transaction as the bookmark flight.
|
||||
struct LeadCardGrid: Layout {
|
||||
let columnCount: Int
|
||||
var spacing: CGFloat = 16
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let width = proposal.width ?? 310
|
||||
let frames = frames(width: width, subviews: subviews)
|
||||
return CGSize(width: width, height: frames.map(\.maxY).max() ?? 0)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
|
||||
subviews: Subviews, cache: inout ()) {
|
||||
let frames = frames(width: bounds.width, subviews: subviews)
|
||||
for (index, subview) in subviews.enumerated() {
|
||||
let frame = frames[index]
|
||||
subview.place(at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
|
||||
anchor: .topLeading,
|
||||
proposal: ProposedViewSize(width: frame.width, height: frame.height))
|
||||
}
|
||||
}
|
||||
|
||||
private func frames(width: CGFloat, subviews: Subviews) -> [CGRect] {
|
||||
let columns = max(1, columnCount)
|
||||
let cardWidth = max(0, (width - CGFloat(columns - 1) * spacing) / CGFloat(columns))
|
||||
var frames: [CGRect] = []
|
||||
var rowY: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
for (index, subview) in subviews.enumerated() {
|
||||
let column = index % columns
|
||||
if column == 0 && index > 0 {
|
||||
rowY += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
let size = subview.sizeThatFits(ProposedViewSize(width: cardWidth, height: nil))
|
||||
frames.append(CGRect(x: CGFloat(column) * (cardWidth + spacing), y: rowY,
|
||||
width: cardWidth, height: size.height))
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
return frames
|
||||
}
|
||||
}
|
||||
75
GasTankAR/Views/Leads/Overview/LeadCardView.swift
Normal file
75
GasTankAR/Views/Leads/Overview/LeadCardView.swift
Normal file
@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadCardView: View {
|
||||
let lead: Lead
|
||||
let isBusy: Bool
|
||||
let onOpen: () -> Void
|
||||
let onBookmark: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Button(action: onOpen) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(lead.id)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.trailing, 36)
|
||||
Text(lead.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(lead.fullAddress)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Label(lead.phoneNumber ?? " ", systemImage: "phone.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(LeadOverviewStyle.blue)
|
||||
.hidden()
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
Divider()
|
||||
HStack(spacing: 12) {
|
||||
LeadStatusView(offerCount: lead.offerCount, unsignedContractCount: lead.unsignedContractCount)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.primary.opacity(0.025), in: .rect)
|
||||
}
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 18))
|
||||
.clipShape(.rect(cornerRadius: 18))
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Interessentendetails öffnen")
|
||||
|
||||
LeadBookmarkButton(isBookmarked: lead.isBookmarked, isBusy: isBusy, action: onBookmark)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LeadBookmarkButton: View {
|
||||
let isBookmarked: Bool
|
||||
let isBusy: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
|
||||
.foregroundStyle(isBookmarked ? Color.red : Color.secondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
.accessibilityLabel(isBookmarked ? "Nicht mehr merken" : "Interessent merken")
|
||||
}
|
||||
}
|
||||
62
GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift
Normal file
62
GasTankAR/Views/Leads/Overview/LeadOverviewHeaderView.swift
Normal file
@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadOverviewHeaderView: View {
|
||||
let session: LoginSession
|
||||
let lastUpdated: Date?
|
||||
let isLoading: Bool
|
||||
let onRefresh: () -> Void
|
||||
let onLogout: () -> Void
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
|
||||
var body: some View {
|
||||
let layout = dynamicTypeSize.isAccessibilitySize
|
||||
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
|
||||
: AnyLayout(HStackLayout(spacing: 20))
|
||||
layout {
|
||||
HStack(spacing: 8) {
|
||||
Image("LoginLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 34, height: 34)
|
||||
.clipShape(.rect(cornerRadius: 8))
|
||||
.accessibilityHidden(true)
|
||||
Text("DrachenGas")
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(LeadOverviewStyle.blue)
|
||||
}
|
||||
Button(action: onRefresh) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
if let lastUpdated {
|
||||
Text("Aktualisiert um \(lastUpdated.formatted(.dateTime.hour().minute().second()))")
|
||||
} else {
|
||||
Text("Aktualisieren")
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
if !dynamicTypeSize.isAccessibilitySize {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
Menu {
|
||||
Button("Abmelden", systemImage: "rectangle.portrait.and.arrow.right", action: onLogout)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
if let displayName = session.displayName {
|
||||
Text(displayName)
|
||||
}
|
||||
Text(session.salesCode)
|
||||
.font(.subheadline.bold())
|
||||
.padding(10)
|
||||
.background(LeadOverviewStyle.blue, in: .capsule)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.foregroundStyle(LeadOverviewStyle.blue)
|
||||
}
|
||||
.accessibilityLabel("Benutzerkonto \(session.displayName ?? session.salesCode)")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
69
GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift
Normal file
69
GasTankAR/Views/Leads/Overview/LeadOverviewStateView.swift
Normal file
@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadOverviewStateView: View {
|
||||
let error: LeadError?
|
||||
let isLoading: Bool
|
||||
let isSearching: Bool
|
||||
let onRetry: () -> Void
|
||||
let onReset: () -> Void
|
||||
let onLogin: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
if isLoading {
|
||||
ProgressView("Interessenten werden geladen …")
|
||||
.padding(40)
|
||||
} else if let error {
|
||||
ContentUnavailableView {
|
||||
Label("Interessenten nicht verfügbar", systemImage: "exclamationmark.icloud")
|
||||
} description: {
|
||||
Text(error.message)
|
||||
} actions: {
|
||||
if error == .unauthorized {
|
||||
Button("Erneut anmelden", action: onLogin)
|
||||
} else if error.allowsRetry {
|
||||
Button("Erneut versuchen", action: onRetry)
|
||||
}
|
||||
if isSearching {
|
||||
Button("Suche zurücksetzen", action: onReset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label(isSearching ? "Keine Interessenten gefunden" : "Noch keine Interessenten",
|
||||
systemImage: isSearching ? "magnifyingglass" : "person.2")
|
||||
} description: {
|
||||
Text(isSearching ? "Passe deine Suche oder den Filter an." : "Dir sind aktuell keine Interessenten zugeordnet.")
|
||||
} actions: {
|
||||
if isSearching { Button("Suche zurücksetzen", action: onReset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct LeadErrorBanner: View {
|
||||
let error: LeadError
|
||||
let onRetry: () -> Void
|
||||
let onLogin: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label {
|
||||
Text(error.message)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
}
|
||||
if error == .unauthorized {
|
||||
Button("Erneut anmelden", action: onLogin)
|
||||
} else if error.allowsRetry {
|
||||
Button("Übersicht aktualisieren", action: onRetry)
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.12), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
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: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
173
GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift
Normal file
173
GasTankAR/Views/Leads/Overview/LeadOverviewViewModel.swift
Normal file
@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LeadOverviewViewModel {
|
||||
var searchText = "" {
|
||||
didSet {
|
||||
guard searchText != oldValue else { return }
|
||||
if query.searchText.isEmpty { restoreCachedList() }
|
||||
}
|
||||
}
|
||||
var selection: LeadOverviewSelection = .newest {
|
||||
didSet { updateDisplayedLeads() }
|
||||
}
|
||||
private(set) var leads: [Lead] = []
|
||||
private(set) var bookmarkedLeads: [Lead] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var lastUpdated: Date?
|
||||
private(set) var error: LeadError?
|
||||
private(set) var actionError: LeadError?
|
||||
private(set) var bookmarkingIDs: Set<String> = []
|
||||
private var loadedLeads: [Lead] = []
|
||||
private var cachedList: [Lead]?
|
||||
private var cachedListUpdatedAt: Date?
|
||||
private var displayedQuery: LeadQuery?
|
||||
private var generation = 0
|
||||
private var bookmarkRevision = 0
|
||||
private var confirmedBookmarks: [String: (revision: Int, value: Bool)] = [:]
|
||||
|
||||
@ObservationIgnored private let salesCode: String
|
||||
@ObservationIgnored private let load: LoadLeadsUseCase
|
||||
@ObservationIgnored private let search: SearchLeadsUseCase
|
||||
@ObservationIgnored private let bookmark: SetLeadBookmarkUseCase
|
||||
|
||||
init(salesCode: String, repository: any LeadRepository) {
|
||||
self.salesCode = salesCode
|
||||
load = LoadLeadsUseCase(repository: repository)
|
||||
search = SearchLeadsUseCase(repository: repository)
|
||||
bookmark = SetLeadBookmarkUseCase(repository: repository)
|
||||
}
|
||||
|
||||
var query: LeadQuery {
|
||||
// Only the search term affects the server request; sorting and filtering stay local.
|
||||
LeadQuery(searchText: searchText).normalized
|
||||
}
|
||||
|
||||
var hasCurrentResults: Bool { displayedQuery == query }
|
||||
var isSessionExpired: Bool { error == .unauthorized || actionError == .unauthorized }
|
||||
|
||||
func reload(debounce: Bool = false, useCachedList: Bool = false) async {
|
||||
if useCachedList && query.searchText.isEmpty && cachedList != nil && !isSessionExpired {
|
||||
restoreCachedList()
|
||||
return
|
||||
}
|
||||
generation += 1
|
||||
let request = generation
|
||||
let requestedBookmarkRevision = bookmarkRevision
|
||||
let requestedQuery = query
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer {
|
||||
if request == generation { isLoading = false }
|
||||
}
|
||||
do {
|
||||
if debounce && !requestedQuery.searchText.isEmpty {
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
let result: [Lead]
|
||||
if requestedQuery == LeadQuery() {
|
||||
result = try await load.execute(salesCode: salesCode)
|
||||
} else {
|
||||
result = try await search.execute(salesCode: salesCode, query: requestedQuery)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
// Query comparison also protects the interval before the next .task starts.
|
||||
guard request == generation, requestedQuery == query else { return }
|
||||
loadedLeads = result.map { lead in
|
||||
var lead = lead
|
||||
if let confirmed = confirmedBookmarks[lead.id],
|
||||
confirmed.revision > requestedBookmarkRevision {
|
||||
lead.isBookmarked = confirmed.value
|
||||
}
|
||||
return lead
|
||||
}
|
||||
updateDisplayedLeads()
|
||||
displayedQuery = requestedQuery
|
||||
lastUpdated = Date()
|
||||
if requestedQuery.searchText.isEmpty {
|
||||
cachedList = loadedLeads
|
||||
cachedListUpdatedAt = lastUpdated
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// A superseded search must not replace data or present an error.
|
||||
} catch {
|
||||
guard request == generation, requestedQuery == query else { return }
|
||||
self.error = error as? LeadError ?? .unexpected
|
||||
}
|
||||
}
|
||||
|
||||
func toggleBookmark(_ lead: Lead) async {
|
||||
if let updated = await saveBookmark(lead) {
|
||||
applyBookmark(updated)
|
||||
}
|
||||
}
|
||||
|
||||
func saveBookmark(_ lead: Lead) async -> Lead? {
|
||||
guard !bookmarkingIDs.contains(lead.id), !isSessionExpired else { return nil }
|
||||
bookmarkingIDs.insert(lead.id)
|
||||
actionError = nil
|
||||
defer { bookmarkingIDs.remove(lead.id) }
|
||||
do {
|
||||
let updated = try await bookmark.execute(!lead.isBookmarked, leadID: lead.id, salesCode: salesCode)
|
||||
try Task.checkCancellation()
|
||||
return updated
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
} catch {
|
||||
actionError = error as? LeadError ?? .unexpected
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyBookmark(_ updated: Lead) {
|
||||
bookmarkRevision += 1
|
||||
confirmedBookmarks[updated.id] = (bookmarkRevision, updated.isBookmarked)
|
||||
if let index = cachedList?.firstIndex(where: { $0.id == updated.id }) {
|
||||
cachedList?[index] = updated
|
||||
}
|
||||
if let index = loadedLeads.firstIndex(where: { $0.id == updated.id }) {
|
||||
loadedLeads[index] = updated
|
||||
updateDisplayedLeads()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDisplayedLeads() {
|
||||
let filtered = selection == .open
|
||||
? loadedLeads.filter { $0.unsignedContractCount > 0 }
|
||||
: loadedLeads
|
||||
leads = filtered.sorted { lhs, rhs in
|
||||
if selection == .alphabetical {
|
||||
let order = lhs.displayName.localizedStandardCompare(rhs.displayName)
|
||||
return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending
|
||||
}
|
||||
return lhs.createdAt == rhs.createdAt ? lhs.id < rhs.id : lhs.createdAt > rhs.createdAt
|
||||
}
|
||||
bookmarkedLeads = leads.filter(\.isBookmarked)
|
||||
}
|
||||
|
||||
func dismissActionError() { actionError = nil }
|
||||
|
||||
private func restoreCachedList() {
|
||||
guard let cachedList, !isSessionExpired else { return }
|
||||
// Invalidate in-flight searches before publishing the original list.
|
||||
generation += 1
|
||||
loadedLeads = cachedList
|
||||
displayedQuery = LeadQuery()
|
||||
lastUpdated = cachedListUpdatedAt
|
||||
error = nil
|
||||
isLoading = false
|
||||
updateDisplayedLeads()
|
||||
}
|
||||
|
||||
func clearSearchText() {
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
func resetSearch() {
|
||||
clearSearchText()
|
||||
selection = .newest
|
||||
}
|
||||
}
|
||||
53
GasTankAR/Views/Leads/Overview/LeadPresentation.swift
Normal file
53
GasTankAR/Views/Leads/Overview/LeadPresentation.swift
Normal file
@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
enum LeadOverviewStyle {
|
||||
static let blue = Color(red: 0, green: 0.44, blue: 0.80)
|
||||
static let successGreen = Color(red: 0.12, green: 0.53, blue: 0.38)
|
||||
static let background = Color(uiColor: .systemGroupedBackground)
|
||||
}
|
||||
|
||||
/// Formatting shared by the overview and the selected lead's detail screen.
|
||||
extension Lead {
|
||||
var displayName: String {
|
||||
companyName ?? [contact?.title, contact?.firstName, contact?.lastName]
|
||||
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
|
||||
var locality: String { "\(address.postalCode) \(address.city)" }
|
||||
|
||||
var fullAddress: String {
|
||||
[address.street, locality].compactMap { $0 }.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
extension LeadError {
|
||||
var message: LocalizedStringResource {
|
||||
switch self {
|
||||
case .offline: "Keine Internetverbindung. Bitte prüfe deine Verbindung."
|
||||
case .timeout: "Der Server antwortet derzeit nicht. Bitte versuche es erneut."
|
||||
case .serviceUnavailable: "Die Interessenten konnten gerade nicht geladen werden."
|
||||
case .unauthorized: "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an."
|
||||
case .forbidden: "Du hast keine Berechtigung für diesen Vorgang."
|
||||
case .notFound: "Dieser Interessent ist nicht mehr verfügbar. Bitte aktualisiere die Übersicht."
|
||||
case .invalidResponse: "Die Serverantwort konnte nicht verarbeitet werden."
|
||||
case .unexpected: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."
|
||||
}
|
||||
}
|
||||
|
||||
var allowsRetry: Bool {
|
||||
self != .unauthorized && self != .forbidden
|
||||
}
|
||||
}
|
||||
|
||||
enum LeadOverviewSelection: String, CaseIterable, Identifiable {
|
||||
case newest, alphabetical, open
|
||||
var id: Self { self }
|
||||
|
||||
var title: LocalizedStringKey {
|
||||
switch self {
|
||||
case .newest: "Neueste"
|
||||
case .alphabetical: "A–Z"
|
||||
case .open: "Offen"
|
||||
}
|
||||
}
|
||||
}
|
||||
51
GasTankAR/Views/Leads/Overview/LeadResultsSection.swift
Normal file
51
GasTankAR/Views/Leads/Overview/LeadResultsSection.swift
Normal file
@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadResultsSection: View {
|
||||
let leads: [Lead]
|
||||
let columns: [GridItem]
|
||||
@Binding var selection: LeadOverviewSelection
|
||||
let cardMotion: Namespace.ID
|
||||
let isBusy: Bool
|
||||
let onOpen: (Lead) -> Void
|
||||
let onBookmark: (Lead) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack {
|
||||
heading
|
||||
Spacer(minLength: 20)
|
||||
picker.frame(width: 300)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
heading
|
||||
picker
|
||||
}
|
||||
}
|
||||
LeadCardGrid(columnCount: columns.count) {
|
||||
ForEach(leads) { lead in
|
||||
LeadCardView(lead: lead, isBusy: isBusy,
|
||||
onOpen: { onOpen(lead) }, onBookmark: { onBookmark(lead) })
|
||||
.matchedGeometryEffect(id: lead.isBookmarked ? "regular-\(lead.id)" : "flight-\(lead.id)",
|
||||
in: cardMotion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var heading: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("Alle Interessenten").font(.title2.bold())
|
||||
Text(leads.count, format: .number).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var picker: some View {
|
||||
Picker("Sortierung und Filter", selection: $selection) {
|
||||
ForEach(LeadOverviewSelection.allCases) { option in
|
||||
Text(option.title).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
35
GasTankAR/Views/Leads/Overview/LeadSearchField.swift
Normal file
35
GasTankAR/Views/Leads/Overview/LeadSearchField.swift
Normal file
@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadSearchField: View {
|
||||
@Binding var text: String
|
||||
var isFocused: FocusState<Bool>.Binding
|
||||
let onClear: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.accessibilityHidden(true)
|
||||
TextField("Firma, Person, Ort oder LeadID", text: $text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.focused(isFocused)
|
||||
.onSubmit { isFocused.wrappedValue = false }
|
||||
.accessibilityLabel("Interessenten suchen")
|
||||
if !text.isEmpty {
|
||||
Button(action: onClear) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Suche löschen")
|
||||
.padding(.vertical, -10)
|
||||
}
|
||||
}
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(18)
|
||||
.background(Color.primary.opacity(0.06), in: .rect(cornerRadius: 15))
|
||||
}
|
||||
}
|
||||
73
GasTankAR/Views/Leads/Overview/LeadStatusView.swift
Normal file
73
GasTankAR/Views/Leads/Overview/LeadStatusView.swift
Normal file
@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeadStatusView: View {
|
||||
let offerCount: Int
|
||||
let unsignedContractCount: Int
|
||||
var highlighted = false
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 8) {
|
||||
LeadOfferBadge(count: offerCount, highlighted: highlighted)
|
||||
contractStatus
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LeadOfferBadge(count: offerCount, highlighted: highlighted)
|
||||
contractStatus
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contractStatus: some View {
|
||||
if offerCount > 0 && unsignedContractCount == 0 {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, LeadOverviewStyle.successGreen)
|
||||
.font(.title3)
|
||||
Text("signiert")
|
||||
.foregroundStyle(highlighted ? Color.white.opacity(0.9) : Color.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Alle Verträge unterschrieben")
|
||||
} else {
|
||||
Text(contractLabel)
|
||||
.foregroundStyle(highlighted ? Color.white.opacity(0.9) : Color.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var contractLabel: LocalizedStringResource {
|
||||
switch unsignedContractCount {
|
||||
case 0: "Nichts zu unterschreiben"
|
||||
case 1: "1 Vertrag offen"
|
||||
default: "\(unsignedContractCount) Verträge offen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LeadOfferBadge: View {
|
||||
let count: Int
|
||||
let highlighted: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundStyle(highlighted ? .white : (count == 0 ? Color.secondary : LeadOverviewStyle.blue))
|
||||
.background(
|
||||
highlighted ? Color.white.opacity(0.25) : (count == 0 ? Color.primary.opacity(0.05) : LeadOverviewStyle.blue.opacity(0.08)),
|
||||
in: .capsule
|
||||
)
|
||||
}
|
||||
|
||||
private var label: LocalizedStringResource {
|
||||
switch count {
|
||||
case 0: "Kein Angebot"
|
||||
case 1: "1 Angebot"
|
||||
default: "\(count) Angebote"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user