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

264 lines
9.3 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 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) {
delete(photo)
}
}
}
private func thumbnail(for photo: ProjectPhoto) -> some View {
PhotoThumbnail(imageData: 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 ein einzelnes Foto mit Schließen- und Löschen-Aktion.
private struct PhotoViewer: View {
let photo: ProjectPhoto
let onDelete: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
if let image = UIImage(data: photo.imageData) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
}
if let coordinate = photo.coordinate {
VStack(alignment: .leading, spacing: 8) {
Text("Karte")
.font(.headline)
PhotoLocationCard(coordinate: coordinate)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
.background(Color(.systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete()
dismiss()
}
}
}
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle(photo.creationDate.formatted(.dateTime.day().month().year().hour().minute()))
.navigationBarTitleDisplayMode(.inline)
}
}
}
/// 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") {
PhotoViewer(photo: PreviewSupport.samplePhoto()) {}
}