LocationTracker: - isPaused, pausedAt, accumulatedPausedSeconds - pause()/resume()/restore() Methoden - effectiveElapsedSeconds rechnet Pausen raus - restore() für nach App-Crash: Offline-Lücke wird als Pause gezählt ActiveWalk @Model (SwiftData): - startedAt, lastUpdate, pausedAt, accumulatedPausedSeconds, pointsData - Container in BanYaroGoApp registriert TrackingView: - Persistenz alle 5s via Timer - confirmationDialog beim Erscheinen wenn ActiveWalk vorhanden: Fortsetzen / Jetzt speichern / Verwerfen - Pause/Resume-Button + Stop-Button - Floating Kamera-Button rechts unten - Foto-Counter in der Stats-Karte - Pause-Badge oben links bei Pause CameraPicker: UIImagePickerController-Wrapper (Fallback auf Library im Simulator). FinishWalkSheet: initialPhotos: [Data] für Kamera-Fotos während Tour. RouteDetailView: PhotosPicker zum Hinzufügen von Fotos zu bestehender Tour, sequentieller Upload mit Progress, Detail wird nach Upload refreshed. NSCameraUsageDescription in BanYaroGo-Info.plist.
293 lines
9.6 KiB
Swift
293 lines
9.6 KiB
Swift
import SwiftUI
|
|
import PhotosUI
|
|
|
|
struct FinishWalkSheet: View {
|
|
let points: [GPSPoint]
|
|
let durationSeconds: Int
|
|
let distanceMeters: Double
|
|
let initialPhotos: [Data]
|
|
let onDiscard: () -> Void
|
|
let onSaved: () -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var name: String
|
|
@State private var selectedDogIds: Set<Int> = []
|
|
@State private var dogs: [Dog] = []
|
|
@State private var isLoadingDogs = false
|
|
|
|
@State private var photoSelection: [PhotosPickerItem] = []
|
|
@State private var photoData: [Data] = []
|
|
@State private var loadingPhotos = false
|
|
|
|
@State private var saveState: SaveState = .idle
|
|
@State private var errorMessage: String?
|
|
|
|
private enum SaveState: Equatable {
|
|
case idle
|
|
case savingRoute
|
|
case uploadingPhotos(done: Int, total: Int)
|
|
}
|
|
|
|
private let shortDistanceThreshold: Double = 50 // meters
|
|
|
|
init(
|
|
points: [GPSPoint],
|
|
durationSeconds: Int,
|
|
distanceMeters: Double,
|
|
initialPhotos: [Data] = [],
|
|
onDiscard: @escaping () -> Void,
|
|
onSaved: @escaping () -> Void
|
|
) {
|
|
self.points = points
|
|
self.durationSeconds = durationSeconds
|
|
self.distanceMeters = distanceMeters
|
|
self.initialPhotos = initialPhotos
|
|
self.onDiscard = onDiscard
|
|
self.onSaved = onSaved
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "de_DE")
|
|
formatter.dateStyle = .medium
|
|
_name = State(initialValue: "Gassi am \(formatter.string(from: .now))")
|
|
_photoData = State(initialValue: initialPhotos)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
if distanceMeters < shortDistanceThreshold {
|
|
shortDistanceWarning
|
|
}
|
|
|
|
Section("Stats") {
|
|
LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000))
|
|
LabeledContent("Dauer", value: durationLabel)
|
|
LabeledContent("Punkte", value: "\(points.count)")
|
|
}
|
|
|
|
Section("Name") {
|
|
TextField("Name der Tour", text: $name)
|
|
}
|
|
|
|
Section("Hunde") {
|
|
if isLoadingDogs && dogs.isEmpty {
|
|
HStack { ProgressView(); Text("Lade Hunde…") }
|
|
} else if dogs.isEmpty {
|
|
Text("Keine Hunde gefunden. Wird gespeichert ohne Hund-Zuordnung.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(dogs) { dog in
|
|
dogRow(dog)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
PhotosPicker(
|
|
selection: $photoSelection,
|
|
maxSelectionCount: 10,
|
|
matching: .images
|
|
) {
|
|
Label(
|
|
photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern",
|
|
systemImage: "photo.badge.plus"
|
|
)
|
|
}
|
|
|
|
if loadingPhotos {
|
|
HStack { ProgressView(); Text("Lade Fotos…").font(.caption).foregroundStyle(.secondary) }
|
|
}
|
|
|
|
if !photoData.isEmpty {
|
|
photoStrip
|
|
}
|
|
} header: {
|
|
Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))")
|
|
}
|
|
|
|
if let errorMessage {
|
|
Section {
|
|
Text(errorMessage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Tour speichern")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Verwerfen", role: .destructive) {
|
|
onDiscard()
|
|
dismiss()
|
|
}
|
|
.disabled(saveState != .idle)
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
saveToolbarItem
|
|
}
|
|
}
|
|
.task { await loadDogs() }
|
|
.onChange(of: photoSelection) { _, newItems in
|
|
Task { await loadPhotos(from: newItems) }
|
|
}
|
|
.interactiveDismissDisabled(saveState != .idle)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var saveToolbarItem: some View {
|
|
switch saveState {
|
|
case .idle:
|
|
Button("Speichern") {
|
|
Task { await save() }
|
|
}
|
|
.disabled(canSave == false)
|
|
case .savingRoute:
|
|
ProgressView()
|
|
case .uploadingPhotos(let done, let total):
|
|
Text("\(done)/\(total)")
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var shortDistanceWarning: some View {
|
|
Section {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
Text("Sehr kurze Tour (\(Int(distanceMeters)) m). Du kannst trotzdem speichern.")
|
|
.font(.footnote)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var photoStrip: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(Array(photoData.enumerated()), id: \.offset) { _, data in
|
|
if let img = UIImage(data: data) {
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 80, height: 80)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dogRow(_ dog: Dog) -> some View {
|
|
let selected = selectedDogIds.contains(dog.id)
|
|
return HStack {
|
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(selected ? Color.accentColor : .secondary)
|
|
VStack(alignment: .leading) {
|
|
Text(dog.name)
|
|
if let rasse = dog.rasse, !rasse.isEmpty {
|
|
Text(rasse).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if selected {
|
|
selectedDogIds.remove(dog.id)
|
|
} else {
|
|
selectedDogIds.insert(dog.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var durationLabel: String {
|
|
let mins = durationSeconds / 60
|
|
let secs = durationSeconds % 60
|
|
if mins >= 60 {
|
|
return "\(mins / 60) h \(mins % 60) min"
|
|
}
|
|
return "\(mins) min \(secs) s"
|
|
}
|
|
|
|
private var canSave: Bool {
|
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
&& points.count >= 2
|
|
&& saveState == .idle
|
|
}
|
|
|
|
private func loadDogs() async {
|
|
isLoadingDogs = true
|
|
defer { isLoadingDogs = false }
|
|
do {
|
|
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
|
|
self.dogs = fetched
|
|
if fetched.count == 1 {
|
|
selectedDogIds = [fetched[0].id]
|
|
}
|
|
} catch {
|
|
print("FinishWalkSheet loadDogs failed: \(error)")
|
|
}
|
|
}
|
|
|
|
private func loadPhotos(from items: [PhotosPickerItem]) async {
|
|
loadingPhotos = true
|
|
defer { loadingPhotos = false }
|
|
var loaded: [Data] = []
|
|
for item in items {
|
|
if let data = try? await item.loadTransferable(type: Data.self) {
|
|
loaded.append(data)
|
|
}
|
|
}
|
|
photoData = loaded
|
|
}
|
|
|
|
private func save() async {
|
|
errorMessage = nil
|
|
saveState = .savingRoute
|
|
|
|
let body = RouteCreateBody(
|
|
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
gpsTrack: points,
|
|
distanzKm: distanceMeters / 1000,
|
|
dauerMin: max(1, durationSeconds / 60),
|
|
dogIds: Array(selectedDogIds),
|
|
isPublic: false
|
|
)
|
|
|
|
let route: RouteDetail
|
|
do {
|
|
route = try await APIClient.shared.post("/api/routes", body: body)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
saveState = .idle
|
|
return
|
|
}
|
|
|
|
if !photoData.isEmpty {
|
|
for (index, raw) in photoData.enumerated() {
|
|
saveState = .uploadingPhotos(done: index, total: photoData.count)
|
|
let resized = ImageResize.resizedJPEG(from: raw)
|
|
do {
|
|
try await APIClient.shared.uploadFile(
|
|
"/api/routes/\(route.id)/photo",
|
|
filename: "photo_\(index + 1).jpg",
|
|
data: resized
|
|
)
|
|
} catch {
|
|
errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
|
|
saveState = .idle
|
|
onSaved()
|
|
return
|
|
}
|
|
}
|
|
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
|
|
}
|
|
|
|
onSaved()
|
|
dismiss()
|
|
}
|
|
}
|