235 lines
7.6 KiB
Swift
235 lines
7.6 KiB
Swift
//
|
||
// ARStatusOverlay.swift
|
||
// GasTankAR
|
||
//
|
||
// Created by Dennis Nemec on 26.08.26.
|
||
//
|
||
|
||
import SwiftUI
|
||
import ARKit
|
||
|
||
/// Dezente, nicht-distraktive Overlays über der AR-View: ein schmaler Status-Banner
|
||
/// oben sowie Aktions-Karten unten für die Entscheidungs-States der State-Machine.
|
||
struct ARStatusOverlay: View {
|
||
let controller: ARSessionController
|
||
let onCancel: () -> Void
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// Fadenkreuz exakt im Bildschirmzentrum – dort trifft der Raycast.
|
||
if controller.state == .selectLocation {
|
||
crosshair
|
||
}
|
||
|
||
VStack {
|
||
if let message = controller.statusMessage {
|
||
statusBanner(message)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
bottomContent
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
}
|
||
.padding()
|
||
}
|
||
.animation(.easeInOut(duration: 0.25), value: controller.statusMessage)
|
||
.animation(.easeInOut(duration: 0.25), value: controller.state)
|
||
.animation(.easeInOut(duration: 0.25), value: controller.isTankSelected)
|
||
}
|
||
|
||
// MARK: - Fadenkreuz & Auslöser (Kamera-Style)
|
||
|
||
private var crosshair: some View {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(.white.opacity(0.9), lineWidth: 2)
|
||
.frame(width: 44, height: 44)
|
||
Circle()
|
||
.fill(.white)
|
||
.frame(width: 5, height: 5)
|
||
}
|
||
.shadow(radius: 2)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
private var shutterButton: some View {
|
||
VStack(spacing: 10) {
|
||
Text("Tank platzieren")
|
||
.font(.subheadline.weight(.medium))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
|
||
Button {
|
||
controller.capture()
|
||
} label: {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(.white, lineWidth: 4)
|
||
.frame(width: 74, height: 74)
|
||
Circle()
|
||
.fill(.white)
|
||
.frame(width: 60, height: 60)
|
||
}
|
||
.shadow(radius: 4)
|
||
.contentShape(.circle)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Tank platzieren")
|
||
}
|
||
}
|
||
|
||
// MARK: - Status-Banner
|
||
|
||
private func statusBanner(_ message: String) -> some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.yellow)
|
||
Text(message)
|
||
.font(.footnote)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
// MARK: - Untere Aktions-Inhalte je State
|
||
|
||
@ViewBuilder
|
||
private var bottomContent: some View {
|
||
switch controller.state {
|
||
case .selectLocation:
|
||
shutterButton
|
||
|
||
case .showOptions:
|
||
actionCard(
|
||
title: "Zu wenige Merkmale erkannt",
|
||
message: "Die Umgebung bietet ARKit zu wenig Struktur."
|
||
) {
|
||
Button("Weiter umhergehen") { controller.keepMoving() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Marker platzieren") { controller.placeAnchorMarker() }
|
||
.buttonStyle(.bordered)
|
||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
|
||
case .anchorDetection:
|
||
hintCard(
|
||
icon: "viewfinder",
|
||
text: "Richte die Kamera auf den Marker, um ihn zu erkennen.",
|
||
showsProgress: true,
|
||
onCancel: onCancel
|
||
)
|
||
|
||
case .askToCancel:
|
||
actionCard(
|
||
title: "Marker nicht erkannt",
|
||
message: "Der Marker konnte nicht zuverlässig erkannt werden."
|
||
) {
|
||
Button("Erneut versuchen") { controller.tryAgainAnchor() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
|
||
case .relocating:
|
||
hintCard(
|
||
icon: "arrow.triangle.2.circlepath",
|
||
text: "Position wird wiederhergestellt …",
|
||
showsProgress: true
|
||
)
|
||
|
||
case .relocationFailed:
|
||
actionCard(
|
||
title: "Position nicht gefunden",
|
||
message: "Die gespeicherte Position konnte nicht wiederhergestellt werden. Neu starten und den Tank neu platzieren?"
|
||
) {
|
||
Button("Neu starten & platzieren", systemImage: "arrow.counterclockwise") {
|
||
controller.restart()
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Weiter versuchen") {
|
||
controller.retryRelocation()
|
||
}
|
||
.buttonStyle(.bordered)
|
||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
|
||
case .immersive:
|
||
if controller.isTankSelected {
|
||
TankManipulationPanel(controller: controller)
|
||
} else if controller.measurementPhase == .idle && controller.pipeRoutingPhase == .idle {
|
||
Text("Tank antippen zum Neu-Platzieren · Ziehen / 2 Finger / Pinch zum Anpassen")
|
||
.font(.caption)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
}
|
||
|
||
default:
|
||
// initializingWorld / initializingNotPossible / userMovesAround übernimmt das
|
||
// Coaching-Overlay; immersive/worldNotInitialized brauchen keine Aktion.
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
// MARK: - Bausteine
|
||
|
||
private func hintCard(
|
||
icon: String,
|
||
text: String,
|
||
showsProgress: Bool = false,
|
||
onCancel: (() -> Void)? = nil
|
||
) -> some View {
|
||
HStack(spacing: 12) {
|
||
if showsProgress {
|
||
ProgressView()
|
||
} else {
|
||
Image(systemName: icon)
|
||
.font(.title3)
|
||
.foregroundStyle(.tint)
|
||
}
|
||
Text(text)
|
||
.font(.subheadline)
|
||
Spacer(minLength: 0)
|
||
if let onCancel {
|
||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||
}
|
||
|
||
private func actionCard<Buttons: View>(
|
||
title: String,
|
||
message: String,
|
||
@ViewBuilder buttons: () -> Buttons
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.headline)
|
||
Text(message)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
VStack(spacing: 8) {
|
||
buttons()
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.padding()
|
||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||
}
|
||
}
|