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