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