Initial commit for first version of project

This commit is contained in:
Dennis Nemec
2026-09-02 20:54:36 +02:00
parent 3d9f1ab6bc
commit 992bb32025
56 changed files with 5139 additions and 31 deletions

View File

@ -0,0 +1,263 @@
//
// 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()) {}
}