// // 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 @AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0 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) } if controller.selectedPipeSegmentIndex != nil { pipeSegmentRemovalOverlay(controller: controller) } Color.white .opacity(flashOpacity) .ignoresSafeArea() .allowsHitTesting(false) .onChange(of: controller.snapshotFlashCounter) { flashOpacity = 0.9 withAnimation(.easeOut(duration: 0.35)) { flashOpacity = 0 } } } } .overlay(alignment: .topLeading) { Button { dismiss() } label: { Image(systemName: "arrow.left") .font(.title2.weight(.semibold)) .frame(width: 44, height: 44) .contentShape(.rect) } .buttonStyle(.plain) .foregroundStyle(.white) .shadow(radius: 3) .accessibilityLabel("Zurück") .padding(.leading, 12) .safeAreaPadding(.top) } .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, arExposure: arExposure ) } } /// Präsentiert den Benennungs-Alert, sobald beide Punkte gesetzt sind. private var namingBinding: Binding { 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) } } private func pipeSegmentRemovalOverlay(controller: ARSessionController) -> some View { VStack { Spacer() HStack(spacing: 12) { Text("Verbindung entfernen?") .font(.subheadline.weight(.medium)) Button("Abbrechen") { controller.cancelPipeSegmentRemoval() } .buttonStyle(.bordered) Button("Entfernen", role: .destructive) { controller.confirmPipeSegmentRemoval() } .buttonStyle(.borderedProminent) } .padding(.horizontal, 14) .padding(.vertical, 10) .background(.ultraThinMaterial, in: .capsule) } .padding() .transition(.move(edge: .bottom).combined(with: .opacity)) } // 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) } } } }