Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
140
GasTankAR/Views/AR/Projects/ARProjectCreateView.swift
Normal file
140
GasTankAR/Views/AR/Projects/ARProjectCreateView.swift
Normal file
@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ARProjectCreateView: View {
|
||||
let isEditing: Bool
|
||||
let onCreate: (String, Tank, Bool) -> Void
|
||||
@State private var name = ""
|
||||
@State private var tank = Tank()
|
||||
@Environment(\.tankCatalog) private var catalog
|
||||
|
||||
init(project: ARProject? = nil, onCreate: @escaping (String, Tank, Bool) -> Void) {
|
||||
isEditing = project != nil
|
||||
self.onCreate = onCreate
|
||||
_name = State(initialValue: project?.name ?? "")
|
||||
_tank = State(initialValue: project?.tank ?? Tank())
|
||||
}
|
||||
|
||||
private var canCreate: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& catalog.definition(for: tank) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
VStack(spacing: 8) {
|
||||
ARProjectTankImage(tank: tank)
|
||||
.frame(height: 150)
|
||||
.frame(maxWidth: .infinity)
|
||||
Text("\(tank.size.volumeTitle) · \(tank.placement.displayName) · \(catalog.displayName(for: tank.color))")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
Section("Name") {
|
||||
TextField("Projektname", text: $name)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
Section("Tankkonfiguration") {
|
||||
ARProjectConfigurationView(tank: $tank)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Projekt bearbeiten" : "Neues Projekt")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isEditing ? "Speichern" : "Anlegen") { onCreate(name, tank, false) }
|
||||
.disabled(!canCreate)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button { onCreate(name, tank, true) } label: {
|
||||
Label(isEditing ? "Speichern und AR-Modus starten" : "Anlegen und AR-Modus starten", systemImage: "arkit")
|
||||
.font(.headline).frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canCreate)
|
||||
.padding()
|
||||
.background(.bar)
|
||||
}
|
||||
.onAppear { tank = catalog.normalized(tank) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ARProjectConfigurationView: View {
|
||||
@Binding var tank: Tank
|
||||
@Environment(\.tankCatalog) private var catalog
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Volumen").font(.subheadline)
|
||||
Picker("Volumen", selection: $tank.size) {
|
||||
ForEach(catalog.sizes(for: tank.placement)) { size in
|
||||
Text(size.volumeTitle).tag(size)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Aufstellung").font(.subheadline)
|
||||
Picker("Aufstellung", selection: $tank.placement) {
|
||||
ForEach(catalog.placements) { placement in
|
||||
Text(placement.displayName).tag(placement)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Text("Farbe")
|
||||
Spacer()
|
||||
ForEach(catalog.colors(for: tank.size, placement: tank.placement)) { color in
|
||||
Button { tank.color = color } label: {
|
||||
Circle().fill(swatch(color))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay { Circle().strokeBorder(.gray.opacity(0.5), lineWidth: 1) }
|
||||
.padding(4)
|
||||
.overlay { Circle().strokeBorder(tank.color == color ? Color.accentColor : .clear, lineWidth: 2) }
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(catalog.displayName(for: color))
|
||||
.accessibilityAddTraits(tank.color == color ? .isSelected : [])
|
||||
}
|
||||
Text(catalog.displayName(for: tank.color)).foregroundStyle(.secondary).font(.subheadline)
|
||||
}
|
||||
.onChange(of: tank.placement) { _, _ in normalize() }
|
||||
.onChange(of: tank.size) { _, _ in normalize() }
|
||||
}
|
||||
|
||||
private func normalize() {
|
||||
tank = catalog.normalized(tank)
|
||||
tank.showLidOnly = false
|
||||
}
|
||||
|
||||
private func swatch(_ color: TankColor) -> Color {
|
||||
switch color {
|
||||
case .white: .white
|
||||
case .green: Color(red: 0.49, green: 0.65, blue: 0.42)
|
||||
case .olive: Color(red: 0.37, green: 0.40, blue: 0.22)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Projekt konfigurieren") {
|
||||
NavigationStack {
|
||||
ARProjectCreateView { _, _, _ in }
|
||||
}
|
||||
.frame(width: 600, height: 760)
|
||||
}
|
||||
|
||||
#Preview("Projekt konfigurieren – Dunkel") {
|
||||
NavigationStack {
|
||||
ARProjectCreateView { _, _, _ in }
|
||||
}
|
||||
.frame(width: 600, height: 760)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
31
GasTankAR/Views/AR/Projects/ARProjectTankImage.swift
Normal file
31
GasTankAR/Views/AR/Projects/ARProjectTankImage.swift
Normal file
@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ARProjectTankImage: View {
|
||||
let tank: Tank
|
||||
@Environment(\.tankCatalog) private var catalog
|
||||
@State private var image: UIImage?
|
||||
@State private var failed = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFit()
|
||||
} else if failed {
|
||||
Image(systemName: "cube.transparent").foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Tankvorschau nicht verfügbar")
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.task(id: tank) {
|
||||
image = nil
|
||||
failed = false
|
||||
await Task.yield()
|
||||
guard !Task.isCancelled else { return }
|
||||
if let definition = catalog.definition(for: tank) {
|
||||
image = TankThumbnailRenderer.shared.image(for: definition)
|
||||
}
|
||||
failed = image == nil
|
||||
}
|
||||
}
|
||||
}
|
||||
202
GasTankAR/Views/AR/Projects/ARProjectsSheet.swift
Normal file
202
GasTankAR/Views/AR/Projects/ARProjectsSheet.swift
Normal file
@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ARProjectsSheet: View {
|
||||
let onOpen: (UUID) -> Void
|
||||
@State private var model: ARProjectsViewModel
|
||||
@State private var creating = false
|
||||
@State private var editing: ARProject?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(service: any ARProjectsProviding, onOpen: @escaping (UUID) -> Void) {
|
||||
self.onOpen = onOpen
|
||||
_model = State(initialValue: ARProjectsViewModel(service: service))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ARProjectListView(supplied: model.supplied,
|
||||
custom: model.custom, onOpen: onOpen, onDelete: model.delete, onEdit: { editing = $0 }, onCreate: { creating = true })
|
||||
.overlay { if model.isLoading { ProgressView() } }
|
||||
.navigationTitle("AR-Projekte")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen", systemImage: "xmark") { dismiss() }
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Neues Projekt", systemImage: "plus") { creating = true }
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $creating) {
|
||||
ARProjectCreateView { name, tank, start in
|
||||
guard let project = model.create(name: name, tank: tank) else { return }
|
||||
creating = false
|
||||
if start { onOpen(project.id) }
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { editing != nil }, set: { if !$0 { editing = nil } }
|
||||
)) {
|
||||
if let project = editing {
|
||||
ARProjectCreateView(project: project) { name, tank, start in
|
||||
guard let updated = model.update(id: project.id, name: name, tank: tank) else { return }
|
||||
editing = nil
|
||||
if start { onOpen(updated.id) }
|
||||
}
|
||||
.id(project.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { model.load() }
|
||||
.alert("Projektaktion fehlgeschlagen",
|
||||
isPresented: Binding(get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } })) {
|
||||
Button("OK", role: .cancel) { model.errorMessage = nil }
|
||||
} message: { Text(model.errorMessage ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
struct ARProjectListView: View {
|
||||
let supplied: [ARProject]
|
||||
let custom: [ARProject]
|
||||
let onOpen: (UUID) -> Void
|
||||
let onDelete: (UUID) -> Void
|
||||
let onEdit: (ARProject) -> Void
|
||||
let onCreate: () -> Void
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !supplied.isEmpty {
|
||||
Section {
|
||||
ForEach(supplied) { project in
|
||||
HStack(spacing: 12) {
|
||||
ARProjectRow(project: project)
|
||||
Button { onOpen(project.id) } label: {
|
||||
Label("AR", systemImage: "arkit")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("AR-Modus starten: " + project.name)
|
||||
}
|
||||
|
||||
}
|
||||
} header: { Text("Bereitgestellte Projekte") }
|
||||
footer: { Text("Diese Projekte werden automatisch anhand der Angebote bereitgestellt.") }
|
||||
}
|
||||
|
||||
Section("Eigene Projekte") {
|
||||
ForEach(custom) { project in
|
||||
HStack(spacing: 12) {
|
||||
Button { onEdit(project) } label: {
|
||||
ARProjectRow(project: project).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Projektkonfiguration bearbeiten")
|
||||
Button { onOpen(project.id) } label: {
|
||||
Label("AR", systemImage: "arkit")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("AR-Modus starten: " + project.name)
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.accessibilityHidden(true)
|
||||
Button { onEdit(project) } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Projekt bearbeiten: " + project.name)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button("Löschen", systemImage: "trash", role: .destructive) {
|
||||
onDelete(project.id)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
Button(action: onCreate) {
|
||||
Label("Neues Projekt", systemImage: "plus.circle.fill")
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
|
||||
struct ARProjectRow: View {
|
||||
let project: ARProject
|
||||
@Environment(\.tankCatalog) private var catalog
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ARProjectTankImage(tank: project.tank)
|
||||
.frame(width: 76, height: 60)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(project.name).font(.headline)
|
||||
if typeSize.isAccessibilitySize {
|
||||
configuration
|
||||
reference
|
||||
} else {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
configuration
|
||||
Spacer(minLength: 4)
|
||||
reference
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var configuration: some View {
|
||||
Text("\(project.tank.size.volumeTitle) · \(project.tank.placement.displayName) · \(catalog.displayName(for: project.tank.color))")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var reference: some View {
|
||||
if let reference = project.reference {
|
||||
Text(reference).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview("AR-Projekte") {
|
||||
ARProjectsSheet(service: ARProjectsPreviewService()) { _ in }
|
||||
.frame(maxWidth: 600, maxHeight: 700)
|
||||
}
|
||||
|
||||
private struct ARProjectsPreviewService: ARProjectsProviding {
|
||||
func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject {
|
||||
ARProject(id: id, name: name, tank: tank, createdAt: .now, source: .custom, reference: nil)
|
||||
}
|
||||
|
||||
func deleteProject(id: UUID) throws {}
|
||||
|
||||
func loadProjects() throws -> [ARProject] {
|
||||
[
|
||||
ARProject(id: UUID(), name: "Flüssiggastank 2.700 l oberirdisch", tank: Tank(),
|
||||
createdAt: .now, source: .supplied, reference: "A-2026-1182"),
|
||||
ARProject(id: UUID(), name: "Flüssiggastank 4.850 l unterirdisch",
|
||||
tank: Tank(size: .t2_1, color: .olive, placement: .belowGround),
|
||||
createdAt: .now, source: .supplied, reference: "A-2026-1197"),
|
||||
ARProject(id: UUID(), name: "Variante hinter dem Haus", tank: Tank(color: .green),
|
||||
createdAt: .now, source: .custom, reference: nil)
|
||||
]
|
||||
}
|
||||
|
||||
func createProject(name: String, tank: Tank) throws -> ARProject {
|
||||
ARProject(id: UUID(), name: name, tank: tank, createdAt: .now, source: .custom, reference: nil)
|
||||
}
|
||||
}
|
||||
55
GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift
Normal file
55
GasTankAR/Views/AR/Projects/ARProjectsViewModel.swift
Normal file
@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor @Observable
|
||||
final class ARProjectsViewModel {
|
||||
private(set) var supplied: [ARProject] = []
|
||||
private(set) var custom: [ARProject] = []
|
||||
private(set) var isLoading = true
|
||||
var errorMessage: String?
|
||||
private let service: any ARProjectsProviding
|
||||
|
||||
init(service: any ARProjectsProviding) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let projects = try service.loadProjects()
|
||||
supplied = projects.filter { $0.source == .supplied }
|
||||
custom = projects.filter { $0.source == .custom }
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func update(id: UUID, name: String, tank: Tank) -> ARProject? {
|
||||
do {
|
||||
let project = try service.updateProject(id: id, name: name, tank: tank)
|
||||
if let index = custom.firstIndex(where: { $0.id == id }) { custom[index] = project }
|
||||
return project
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func delete(id: UUID) {
|
||||
do {
|
||||
try service.deleteProject(id: id)
|
||||
supplied.removeAll { $0.id == id }
|
||||
custom.removeAll { $0.id == id }
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func create(name: String, tank: Tank) -> ARProject? {
|
||||
do {
|
||||
let project = try service.createProject(name: name, tank: tank)
|
||||
custom.insert(project, at: 0)
|
||||
return project
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user