Files
Gas-Tank-AR-App/GasTankAR/Views/TankAR/ARStatusOverlay.swift
2026-09-02 20:54:36 +02:00

235 lines
7.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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))
}
}