Files
Gas-Tank-AR-App/GasTankAR/Views/GalleryView.swift
Dennis Nemec b8536d4182 Add tank catalog with USDZ models and tank selection
Introduce TankDefinition/TankCatalog loaded from Tanks.json, bundle the
Drachengas USDZ tank models, add tank selection and preview views, and
update the AR experience, editor and project views to use the catalog.
Includes tests for catalog loading and model selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:12:39 +02:00

622 lines
22 KiB
Swift
Raw 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.

//
// GalleryView.swift
// GasTankAR
//
// Created by Dennis Nemec on 27.08.26.
//
import SwiftUI
import SwiftData
import MapKit
import UIKit
import CoreLocation
/// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts.
/// Anzeige wie in der Fotos-App (Raster), gruppiert nach Aufnahmedatum mit
/// angehefteten Abschnitts-Überschriften – analog zur Buchstaben-Gruppierung der Kontakte-App.
struct GalleryView: View {
@Bindable var project: Project
@Environment(\.modelContext) private var modelContext
@State private var selectedPhoto: ProjectPhoto?
private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
/// Fotos nach Tag gruppiert und absteigend sortiert (neueste zuerst).
private var sections: [(date: Date, photos: [ProjectPhoto])] {
let groups = Dictionary(grouping: project.photos) { photo in
Calendar.current.startOfDay(for: photo.creationDate)
}
return groups
.map { (date: $0.key, photos: $0.value.sorted { $0.creationDate > $1.creationDate }) }
.sorted { $0.date > $1.date }
}
var body: some View {
Group {
if project.photos.isEmpty {
ContentUnavailableView(
"Keine Fotos",
systemImage: "photo.on.rectangle.angled",
description: Text("Nimm im AR-Modus über den Kamera-Button ein Foto auf.")
)
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 2, pinnedViews: [.sectionHeaders]) {
ForEach(sections, id: \.date) { section in
Section {
ForEach(section.photos) { photo in
thumbnail(for: photo)
}
} header: {
sectionHeader(for: section.date)
}
}
}
}
}
}
.fullScreenCover(item: $selectedPhoto) { photo in
PhotoViewer(
photo: photo,
photos: project.photos.sorted { $0.creationDate > $1.creationDate }
) {
delete(photo)
}
}
}
private func thumbnail(for photo: ProjectPhoto) -> some View {
PhotoThumbnail(imageData: photo.annotatedImageData ?? photo.imageData)
.aspectRatio(1, contentMode: .fit)
.clipped()
.contentShape(.rect)
.onTapGesture { selectedPhoto = photo }
.contextMenu {
Button("Löschen", systemImage: "trash", role: .destructive) {
delete(photo)
}
}
}
private func sectionHeader(for date: Date) -> some View {
Text(Self.headerTitle(for: date))
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
private func delete(_ photo: ProjectPhoto) {
if selectedPhoto == photo { selectedPhoto = nil }
modelContext.delete(photo)
try? modelContext.save()
}
/// Überschrift wie in der Fotos-App: „Heute"/„Gestern", sonst das ausgeschriebene Datum.
private static func headerTitle(for date: Date) -> String {
let calendar = Calendar.current
if calendar.isDateInToday(date) { return "Heute" }
if calendar.isDateInYesterday(date) { return "Gestern" }
return date.formatted(.dateTime.day().month(.wide).year())
}
}
/// Lädt und dekodiert die Bilddaten außerhalb des Main-Threads und zeigt das Ergebnis quadratisch.
struct PhotoThumbnail: View {
let imageData: Data
@State private var image: UIImage?
var body: some View {
GeometryReader { geometry in
ZStack {
Color(.secondarySystemBackground)
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
}
}
.task(id: imageData) {
image = await decode(imageData)
}
}
private func decode(_ data: Data) async -> UIImage? {
await Task.detached(priority: .userInitiated) {
UIImage(data: data)
}.value
}
}
/// Vollbild-Betrachter für die Projektfotos mit Schließen-, Lösch- und Wisch-Aktion.
private enum DrawingTool: String, CaseIterable, Identifiable {
case pen
case eraser
var id: Self { self }
var title: String {
switch self {
case .pen:
"Stift"
case .eraser:
"Radierer"
}
}
}
private struct DrawingStroke {
var points: [CGPoint]
var color: Color
var width: CGFloat
var isEraser: Bool
}
private struct PhotoViewer: View {
let photos: [ProjectPhoto]
let onDelete: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var currentIndex: Int
@State private var isDrawing = false
@State private var strokes: [DrawingStroke] = []
@State private var activeStroke: DrawingStroke?
@State private var drawingTool: DrawingTool = .pen
@State private var drawingColor = Color.red
@State private var drawingWidth: Double = 0.012
@State private var imageRevision = 0
private var currentPhoto: ProjectPhoto {
photos[currentIndex]
}
init(photo: ProjectPhoto, photos: [ProjectPhoto], onDelete: @escaping () -> Void) {
self.photos = photos
self.onDelete = onDelete
_currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0)
}
var body: some View {
NavigationStack {
TabView(selection: $currentIndex) {
ForEach(photos.indices, id: \.self) { index in
photoLayout(for: photos[index])
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.background(Color(.systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if isDrawing {
EmptyView()
} else {
Button("Fertig") { dismiss() }
}
}
ToolbarItem(placement: .topBarTrailing) {
if isDrawing {
Button {
saveDrawing()
} label: {
Image(systemName: "checkmark")
}
.buttonStyle(.glassProminent)
.tint(.accentColor)
.accessibilityLabel("Zeichnung speichern")
} else {
Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete()
dismiss()
}
}
}
}
.onChange(of: currentIndex) {
isDrawing = false
strokes.removeAll()
activeStroke = nil
}
.toolbarBackground(.visible, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 12) {
Button {
currentIndex -= 1
} label: {
Image(systemName: "chevron.left")
}
.disabled(currentIndex == 0)
.accessibilityLabel("Vorheriges Bild")
Text(currentPhoto.creationDate.formatted(.dateTime.day().month().year().hour().minute()))
.font(.subheadline)
Button {
currentIndex += 1
} label: {
Image(systemName: "chevron.right")
}
.disabled(currentIndex == photos.count - 1)
.accessibilityLabel("Nächstes Bild")
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
private func photoLayout(for photo: ProjectPhoto) -> some View {
GeometryReader { geometry in
HStack(spacing: 0) {
imagePane(for: photo)
.padding(.top, 16)
.padding(.leading, 24)
.padding(.bottom, 24)
.padding(.trailing, 24)
.frame(
width: geometry.size.width * 0.75,
height: geometry.size.height
)
metadataPane(for: photo)
.frame(width: geometry.size.width * 0.25)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
private func imagePane(for photo: ProjectPhoto) -> some View {
GeometryReader { geometry in
ZStack {
if let image = UIImage(data: photo.annotatedImageData ?? photo.imageData) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .top
)
.id(imageRevision)
} else {
ContentUnavailableView("Bild nicht verfügbar", systemImage: "photo")
}
if isDrawing {
drawingCanvas(in: geometry.size)
}
}
}
}
private func drawingCanvas(in size: CGSize) -> some View {
Canvas { context, canvasSize in
for stroke in strokes + (activeStroke.map { [$0] } ?? []) {
draw(stroke, in: &context, size: canvasSize)
}
}
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let point = CGPoint(
x: value.location.x / max(size.width, 1),
y: value.location.y / max(size.height, 1)
)
if drawingTool == .eraser {
strokes.removeAll { stroke in
stroke.points.contains { pointDistance($0, point) < drawingWidth * 2 }
}
} else {
if activeStroke == nil {
activeStroke = DrawingStroke(
points: [point],
color: drawingColor,
width: drawingWidth,
isEraser: false
)
} else {
activeStroke?.points.append(point)
}
}
}
.onEnded { _ in
if let activeStroke {
strokes.append(activeStroke)
self.activeStroke = nil
}
}
)
}
private func draw(
_ stroke: DrawingStroke,
in context: inout GraphicsContext,
size: CGSize
) {
guard let first = stroke.points.first else { return }
var path = Path()
path.move(to: CGPoint(x: first.x * size.width, y: first.y * size.height))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: point.x * size.width, y: point.y * size.height))
}
context.stroke(
path,
with: .color(stroke.color),
style: StrokeStyle(
lineWidth: stroke.width * min(size.width, size.height),
lineCap: .round,
lineJoin: .round
)
)
}
private func pointDistance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat {
hypot(lhs.x - rhs.x, lhs.y - rhs.y)
}
private var drawingSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Zeichnen")
.font(.headline)
GlassEffectContainer(spacing: 12) {
HStack(spacing: 12) {
Button {
if isDrawing && drawingTool == .pen {
isDrawing = false
activeStroke = nil
} else {
isDrawing = true
drawingTool = .pen
}
} label: {
Image(systemName: "pencil.tip")
.font(.system(size: 20, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.tint(isDrawing && drawingTool == .pen ? .accentColor : .primary)
.accessibilityLabel(
isDrawing && drawingTool == .pen
? "Zeichnen beenden"
: "Stift auswählen"
)
Button {
isDrawing = true
drawingTool = .eraser
activeStroke = nil
} label: {
Image(systemName: "eraser.fill")
.font(.system(size: 20, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.tint(isDrawing && drawingTool == .eraser ? .accentColor : .primary)
.accessibilityLabel("Radierer auswählen")
if (isDrawing && drawingTool == .pen) || currentPhoto.annotatedImageData != nil {
Spacer()
Button {
if let originalImageData = currentPhoto.originalImageData {
currentPhoto.imageData = originalImageData
}
currentPhoto.originalImageData = nil
currentPhoto.annotatedImageData = nil
strokes.removeAll()
activeStroke = nil
imageRevision += 1
try? modelContext.save()
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 19, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.tint(.primary)
.accessibilityLabel("Zeichnung zurücksetzen")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if isDrawing && drawingTool == .pen {
ColorPicker("Farbe", selection: $drawingColor)
VStack(alignment: .leading, spacing: 4) {
Text("Stiftdicke")
Slider(value: $drawingWidth, in: 0.003...0.04)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func saveDrawing() {
guard let baseImage = UIImage(data: currentPhoto.annotatedImageData ?? currentPhoto.imageData),
!strokes.isEmpty else {
isDrawing = false
return
}
let renderer = UIGraphicsImageRenderer(size: baseImage.size)
let renderedPNG = renderer.pngData { rendererContext in
baseImage.draw(in: CGRect(origin: .zero, size: baseImage.size))
let context = rendererContext.cgContext
for stroke in strokes {
guard let first = stroke.points.first else { continue }
context.setStrokeColor(UIColor(stroke.color).cgColor)
context.setLineWidth(stroke.width * min(baseImage.size.width, baseImage.size.height))
context.setLineCap(.round)
context.setLineJoin(.round)
context.beginPath()
context.move(
to: CGPoint(
x: first.x * baseImage.size.width,
y: first.y * baseImage.size.height
)
)
for point in stroke.points.dropFirst() {
context.addLine(
to: CGPoint(
x: point.x * baseImage.size.width,
y: point.y * baseImage.size.height
)
)
}
context.strokePath()
}
}
if currentPhoto.originalImageData == nil {
currentPhoto.originalImageData = currentPhoto.imageData
}
currentPhoto.imageData = renderedPNG
currentPhoto.annotatedImageData = renderedPNG
try? modelContext.save()
imageRevision += 1
strokes.removeAll()
activeStroke = nil
isDrawing = false
}
private func metadataPane(for photo: ProjectPhoto) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Metadaten")
.font(.title3.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
Text("Aufgenommen")
.font(.subheadline.weight(.semibold))
Text(photo.creationDate.formatted(.dateTime.day().month(.wide).year().hour().minute()))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Divider()
if let coordinate = photo.coordinate {
VStack(alignment: .leading, spacing: 8) {
Text("Standort")
.font(.headline)
PhotoLocationCard(coordinate: coordinate)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Label("Kein Standort gespeichert", systemImage: "mappin.slash")
.font(.headline)
Text("Bei diesem Bild wurden keine Standortdaten aufgenommen.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
drawingSection
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.background(Color(.systemGroupedBackground))
}
}
/// Schwebende Karte, die den Aufnahmeort eines Fotos zeigt: Karten-Vorschau plus
/// (rückwärts-geokodierte) Adresse. Tippen öffnet den Ort in Apple Karten.
private struct PhotoLocationCard: View {
let coordinate: CLLocationCoordinate2D
@State private var address: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Map(
initialPosition: .region(
MKCoordinateRegion(center: coordinate, latitudinalMeters: 350, longitudinalMeters: 350)
)
) {
Marker("Aufnahmeort", coordinate: coordinate)
.tint(.red)
}
.frame(height: 140)
.allowsHitTesting(false)
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.title2)
.foregroundStyle(.red)
VStack(alignment: .leading, spacing: 2) {
Text("Aufnahmeort")
.font(.subheadline.weight(.semibold))
Text(address ?? Self.coordinateText(coordinate))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.forward.app.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.padding(12)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(.rect(cornerRadius: 12))
.contentShape(.rect)
.onTapGesture { openInMaps() }
.accessibilityElement(children: .combine)
.accessibilityLabel("Aufnahmeort, in Apple Karten öffnen")
.task(id: coordinate.latitude + coordinate.longitude) { await reverseGeocode() }
}
private func openInMaps() {
let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
mapItem.name = address ?? "Aufnahmeort"
mapItem.openInMaps()
}
private func reverseGeocode() async {
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
guard let placemark = try? await CLGeocoder().reverseGeocodeLocation(location).first else { return }
let street = [placemark.thoroughfare, placemark.subThoroughfare]
.compactMap { $0 }
.joined(separator: " ")
let city = [placemark.postalCode, placemark.locality]
.compactMap { $0 }
.joined(separator: " ")
let composed = [street, city].filter { !$0.isEmpty }.joined(separator: ", ")
if !composed.isEmpty { address = composed }
}
private static func coordinateText(_ coordinate: CLLocationCoordinate2D) -> String {
String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude)
}
}
#Preview("Galerie") {
let sample = PreviewSupport.galleryProjectContainer()
return NavigationStack {
GalleryView(project: sample.project)
}
.modelContainer(sample.container)
}
#Preview("Foto-Detail") {
let photo = PreviewSupport.samplePhoto()
PhotoViewer(photo: photo, photos: [photo]) {}
}