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>
622 lines
22 KiB
Swift
622 lines
22 KiB
Swift
//
|
||
// 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]) {}
|
||
}
|