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,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)
}
}
}

View 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)
}
}
}
}
}

View 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)
}
}

View 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
}
}

View 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")
}
}

View 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)
}
}

View 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))
}
}

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: {}
)
}
}

View 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
}
}

View 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"
}
}
}

View 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)
}
}

View 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))
}
}

View 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"
}
}
}