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,126 @@
//
// DocuframeView.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import SwiftUI
import SwiftData
/// DOCUframe-Tab: Auswahl aufgenommener Fotos und Übergabe an DOCUframe (Upload) bzw. Mailversand.
struct DocuframeView: View {
let project: Project
@State private var viewModel = DocuframeViewModel()
private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
private var photos: [ProjectPhoto] {
project.photos.sorted { $0.creationDate > $1.creationDate }
}
var body: some View {
Group {
if photos.isEmpty {
ContentUnavailableView(
"Keine Fotos",
systemImage: "photo.on.rectangle.angled",
description: Text("Nimm im AR-Modus Fotos auf, um sie an DOCUframe zu übergeben.")
)
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
selectableThumbnail(photo)
}
}
.padding(2)
}
}
}
.safeAreaInset(edge: .bottom) {
if !photos.isEmpty { actionBar }
}
.alert(
viewModel.result?.title ?? "",
isPresented: resultBinding,
presenting: viewModel.result
) { _ in
Button("OK", role: .cancel) {}
} message: { result in
Text(result.message)
}
}
private func selectableThumbnail(_ photo: ProjectPhoto) -> some View {
let selected = viewModel.isSelected(photo)
return PhotoThumbnail(imageData: photo.imageData)
.aspectRatio(1, contentMode: .fit)
.clipped()
.overlay(alignment: .topTrailing) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.title3)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, selected ? Color.accentColor : Color.black.opacity(0.35))
.padding(6)
.shadow(radius: 2)
}
.overlay {
if selected {
Rectangle().strokeBorder(Color.accentColor, lineWidth: 3)
}
}
.contentShape(.rect)
.onTapGesture { viewModel.toggleSelection(photo) }
}
private var actionBar: some View {
VStack(spacing: 10) {
Text(viewModel.hasSelection ? "\(viewModel.selectionCount) ausgewählt" : "Fotos zum Übertragen auswählen")
.font(.footnote)
.foregroundStyle(.secondary)
VStack(spacing: 8) {
Button {
Task { await viewModel.upload(from: photos, leadID: project.leadID) }
} label: {
Label("In DOCUframe hochladen", systemImage: "arrow.up.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task { await viewModel.sendEmail(from: photos, leadID: project.leadID) }
} label: {
Label("E-Mail senden", systemImage: "envelope")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.controlSize(.large)
.disabled(!viewModel.hasSelection || viewModel.isProcessing)
if viewModel.isProcessing {
ProgressView()
}
}
.padding()
.background(.bar)
}
private var resultBinding: Binding<Bool> {
Binding(
get: { viewModel.result != nil },
set: { if !$0 { viewModel.result = nil } }
)
}
}
#Preview {
let sample = PreviewSupport.galleryProjectContainer()
return NavigationStack {
DocuframeView(project: sample.project)
}
.modelContainer(sample.container)
}

View File

@ -0,0 +1,118 @@
//
// DocuframeViewModel.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import Foundation
import SwiftData
/// Präsentationslogik für den DOCUframe-Tab: verwaltet die Fotoauswahl und stößt Upload bzw.
/// Mailversand über den injizierten ``DocuframeService`` an. Kennt keine UI-Details.
@MainActor
@Observable
final class DocuframeViewModel {
/// Auswahl anhand der stabilen SwiftData-Identität der Fotos.
var selectedIDs: Set<PersistentIdentifier> = []
/// Läuft gerade ein Upload/Mailversand?
private(set) var isProcessing = false
/// Ergebnis des letzten Vorgangs für die Anzeige (nil = kein Hinweis).
var result: DocuframeActionResult?
@ObservationIgnored private let service: DocuframeService
/// Der Service wird injiziert (Default: Stub) – austauschbar für echte Anbindung oder Tests.
init(service: DocuframeService = StubDocuframeService()) {
self.service = service
}
var hasSelection: Bool { !selectedIDs.isEmpty }
var selectionCount: Int { selectedIDs.count }
func isSelected(_ photo: ProjectPhoto) -> Bool {
selectedIDs.contains(photo.persistentModelID)
}
func toggleSelection(_ photo: ProjectPhoto) {
let id = photo.persistentModelID
if selectedIDs.contains(id) {
selectedIDs.remove(id)
} else {
selectedIDs.insert(id)
}
}
func upload(from photos: [ProjectPhoto], leadID: String) async {
await perform(from: photos, leadID: leadID, successText: "in DOCUframe hochgeladen") {
try await service.uploadPhotos($0, projectLeadID: $1)
}
}
func sendEmail(from photos: [ProjectPhoto], leadID: String) async {
await perform(from: photos, leadID: leadID, successText: "per E-Mail versendet") {
try await service.sendEmail($0, projectLeadID: $1)
}
}
// MARK: - Gemeinsame Ausführung
private func perform(
from photos: [ProjectPhoto],
leadID: String,
successText: String,
action: ([DocuframePhoto], String) async throws -> Void
) async {
let selected = photos.filter { selectedIDs.contains($0.persistentModelID) }
guard !selected.isEmpty else {
result = .failure(message: DocuframeError.noPhotosSelected.localizedDescription)
return
}
isProcessing = true
defer { isProcessing = false }
let payload = selected.map(Self.makeDTO)
do {
try await action(payload, leadID)
result = .success(message: "\(selected.count) Foto(s) \(successText).")
} catch {
// Bei HTTP 4xx/5xx (und anderen Fehlern) den Nutzer informieren.
result = .failure(message: error.localizedDescription)
}
}
/// Bildet das Persistenz-Model auf ein persistenzunabhängiges Domänen-DTO ab.
private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto {
DocuframePhoto(
imageData: photo.imageData,
creationDate: photo.creationDate,
latitude: photo.latitude,
longitude: photo.longitude
)
}
}
/// Ergebnis eines DOCUframe-Vorgangs für die Anzeige (Erfolg oder Fehler).
enum DocuframeActionResult {
case success(message: String)
case failure(message: String)
var title: String {
switch self {
case .success: "Erledigt"
case .failure: "Fehler"
}
}
var message: String {
switch self {
case .success(let message), .failure(let message): message
}
}
var isError: Bool {
if case .failure = self { return true }
return false
}
}