625 lines
22 KiB
Swift
625 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)
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
#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]) {}
|
||
}
|
||
#endif
|