264 lines
9.3 KiB
Swift
264 lines
9.3 KiB
Swift
//
|
||
// 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()) {}
|
||
}
|