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