Initial commit for first version of project

This commit is contained in:
Dennis Nemec
2026-09-02 20:54:36 +02:00
parent 3d9f1ab6bc
commit 992bb32025
56 changed files with 5139 additions and 31 deletions

View File

@ -0,0 +1,284 @@
//
// ARExperienceView.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import SwiftUI
import SwiftData
/// Vollbild-AR-Erlebnis für ein Projekt: komponiert AR-View + Status-Overlay und
/// besitzt den `ARSessionController`.
struct ARExperienceView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
let project: Project
@State private var controller: ARSessionController?
@State private var flashOpacity = 0.0
@State private var showMeasurements = false
@State private var measurementName = ""
var body: some View {
ZStack {
if let controller {
ARViewContainer(controller: controller)
.ignoresSafeArea()
ARStatusOverlay(controller: controller, onCancel: { dismiss() })
if controller.state == .immersive {
ARControlOverlay(controller: controller) {
showMeasurements = true
}
.transition(.opacity)
}
if controller.measurementPhase == .awaitingFirstPoint
|| controller.measurementPhase == .awaitingSecondPoint {
measuringOverlay(controller: controller)
}
if controller.pipeRoutingPhase == .placing {
pipeRoutingOverlay(controller: controller)
}
Color.white
.opacity(flashOpacity)
.ignoresSafeArea()
.allowsHitTesting(false)
.onChange(of: controller.snapshotFlashCounter) {
flashOpacity = 0.9
withAnimation(.easeOut(duration: 0.35)) { flashOpacity = 0 }
}
}
}
.navigationTitle(project.customerName)
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showMeasurements) {
if let controller {
MeasurementListView(controller: controller) {
showMeasurements = false
controller.startMeasurement()
}
}
}
.alert("Strecke benennen", isPresented: namingBinding) {
TextField("Bezeichnung", text: $measurementName)
Button("Speichern") {
controller?.finishMeasurement(name: measurementName)
measurementName = ""
}
Button("Abbrechen", role: .cancel) {
controller?.cancelMeasurement()
measurementName = ""
}
} message: {
Text("Gib der Strecke eine Bezeichnung. Sie erscheint auf der Linie.")
}
.task {
guard controller == nil else { return }
let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext)
controller = ARSessionController(persistence: persistence, useLiDAR: lidarEnabled)
}
}
/// Präsentiert den Benennungs-Alert, sobald beide Punkte gesetzt sind.
private var namingBinding: Binding<Bool> {
Binding(
get: { controller?.measurementPhase == .naming },
set: { isPresented in
// Wird der Alert ohne Button-Aktion geschlossen, Messvorgang abbrechen.
if !isPresented, controller?.measurementPhase == .naming {
controller?.cancelMeasurement()
}
}
)
}
/// Vollständiges Mess-Overlay: Fadenkreuz in der Mitte, Hinweis-/Distanz-Banner oben,
/// Auslöser-Button unten (wie beim Platzieren des Tanks).
private func measuringOverlay(controller: ARSessionController) -> some View {
ZStack {
// Fadenkreuz exakt in der Vollbild-Mitte – deckt sich mit dem Raycast-Punkt
// (arView.bounds-Mitte), der die Safe Area ignoriert.
measuringCrosshair
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
VStack {
measuringBanner(for: controller)
Spacer()
measuringShutter(for: controller)
}
.padding()
}
.transition(.opacity)
}
private var measuringCrosshair: some View {
ZStack {
Circle()
.stroke(.yellow.opacity(0.9), lineWidth: 2)
.frame(width: 44, height: 44)
Circle()
.fill(.yellow)
.frame(width: 5, height: 5)
}
.shadow(radius: 2)
.allowsHitTesting(false)
}
private func measuringBanner(for controller: ARSessionController) -> some View {
HStack(spacing: 8) {
Image(systemName: "ruler")
Text(measuringBannerText(for: controller))
.font(.subheadline.weight(.medium))
.monospacedDigit()
Spacer(minLength: 0)
Button("Abbrechen") { controller.cancelMeasurement() }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: .capsule)
}
private func measuringBannerText(for controller: ARSessionController) -> String {
switch controller.measurementPhase {
case .awaitingFirstPoint:
return "Ziele auf den Startpunkt."
case .awaitingSecondPoint:
if let distance = controller.liveDistance {
return String(format: "Abstand: %.2f m", distance)
}
return "Ziele auf den Endpunkt."
default:
return ""
}
}
private func measuringShutter(for controller: ARSessionController) -> some View {
let title = controller.measurementPhase == .awaitingFirstPoint ? "Startpunkt setzen" : "Endpunkt setzen"
return VStack(spacing: 10) {
Text(title)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: .capsule)
Button {
controller.captureMeasurementPoint()
} label: {
ZStack {
Circle()
.stroke(.white, lineWidth: 4)
.frame(width: 74, height: 74)
Circle()
.fill(.yellow)
.frame(width: 60, height: 60)
}
.shadow(radius: 4)
.contentShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
}
}
// MARK: - Rohrführung-Overlay
/// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben,
/// Auslöser + Aktionen unten (gleiche Bedienung wie beim Messen).
private func pipeRoutingOverlay(controller: ARSessionController) -> some View {
ZStack {
pipeCrosshair
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
VStack {
pipeBanner(for: controller)
Spacer()
pipeControls(for: controller)
}
.padding()
}
.transition(.opacity)
}
private var pipeCrosshair: some View {
ZStack {
Circle()
.stroke(.pink.opacity(0.9), lineWidth: 2)
.frame(width: 44, height: 44)
Circle()
.fill(.pink)
.frame(width: 5, height: 5)
}
.shadow(radius: 2)
.allowsHitTesting(false)
}
private func pipeBanner(for controller: ARSessionController) -> some View {
HStack(spacing: 8) {
Image(systemName: "point.3.connected.trianglepath.dotted")
Text(pipeBannerText(for: controller))
.font(.subheadline.weight(.medium))
.monospacedDigit()
Spacer(minLength: 0)
Button("Abbrechen") { controller.cancelPipeRouting() }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: .capsule)
}
private func pipeBannerText(for controller: ARSessionController) -> String {
if controller.pipeWaypointCount == 0 {
return "Ziele auf den Startpunkt der Rohrführung."
}
return "Wegpunkte: \(controller.pipeWaypointCount) · Länge: \(controller.pipeRouteLengthText)"
}
private func pipeControls(for controller: ARSessionController) -> some View {
VStack(spacing: 12) {
Text("Wegpunkt setzen")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: .capsule)
Button {
controller.addPipeWaypoint()
} label: {
ZStack {
Circle()
.stroke(.white, lineWidth: 4)
.frame(width: 74, height: 74)
Circle()
.fill(.pink)
.frame(width: 60, height: 60)
}
.shadow(radius: 4)
.contentShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel("Wegpunkt setzen")
HStack(spacing: 12) {
Button("Rückgängig", systemImage: "arrow.uturn.backward") {
controller.undoLastPipeWaypoint()
}
.buttonStyle(.bordered)
.disabled(controller.pipeWaypointCount == 0)
Button("Fertig", systemImage: "checkmark") {
controller.finishPipeRouting()
}
.buttonStyle(.borderedProminent)
.disabled(controller.pipeWaypointCount < 2)
}
}
}
}