Initial commit for first version of project
This commit is contained in:
284
GasTankAR/Views/TankAR/ARExperienceView.swift
Normal file
284
GasTankAR/Views/TankAR/ARExperienceView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user