banyaro-ios/BanYaroGo/Views/AddDiaryEntrySheet.swift
rene 3059a5eb2c Tagebuch: Geolocation + Reverse-Geocoding beim Anlegen
- OneShotLocation beim Sheet-Öffnen, Toggle 'Standort verwenden' (Default an)
- CLGeocoder reverseGeocodeLocation (de_DE-Locale) füllt locationName aus
  Placemark.name, Fallback thoroughfare + locality. User kann überschreiben.
- gps_lat/gps_lon werden mit dem Eintrag gesendet, wenn Toggle an — die PWA
  rendert daraus die POI-Karte im Tagebuch.
2026-05-30 12:45:18 +02:00

254 lines
10 KiB
Swift

import SwiftUI
import PhotosUI
import CoreLocation
struct AddDiaryEntrySheet: View {
let dogId: Int
let onSaved: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var titel = ""
@State private var text = ""
@State private var date = Date()
@State private var isMilestone = false
@State private var locationName = ""
@State private var tagsInput = ""
@State private var photoSelection: [PhotosPickerItem] = []
@State private var photoData: [Data] = []
@State private var location = OneShotLocation()
@State private var attachLocation = true
@State private var isReverseGeocoding = false
@State private var saveState: SaveState = .idle
@State private var errorMessage: String?
private enum SaveState: Equatable {
case idle
case savingEntry
case uploadingMedia(done: Int, total: Int)
}
var body: some View {
NavigationStack {
Form {
Section("Titel") {
TextField("z. B. Erster Strandbesuch", text: $titel)
}
Section("Text") {
TextField("Was hat dein Hund heute erlebt?", text: $text, axis: .vertical)
.lineLimit(4...10)
}
Section("Datum") {
DatePicker("Datum", selection: $date, displayedComponents: .date)
.environment(\.locale, Locale(identifier: "de_DE"))
}
Section {
Toggle("Meilenstein", isOn: $isMilestone)
TextField("Tags (komma-getrennt)", text: $tagsInput)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Toggle(isOn: $attachLocation) {
Label("Standort verwenden", systemImage: "location.fill")
}
if attachLocation {
if let coord = location.coordinate {
HStack {
Image(systemName: "mappin.and.ellipse")
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 2) {
if !locationName.isEmpty {
Text(locationName).font(.subheadline)
} else if isReverseGeocoding {
Text("Suche Ortsbezeichnung…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Ort wird mitgespeichert")
.font(.subheadline)
}
Text(String(format: "%.5f, %.5f", coord.latitude, coord.longitude))
.font(.caption2.monospacedDigit())
.foregroundStyle(.tertiary)
}
}
} else if location.error != nil {
Label("Standort nicht verfügbar — wird ohne gespeichert", systemImage: "location.slash")
.font(.caption)
.foregroundStyle(.secondary)
} else {
HStack {
ProgressView()
Text("Hole Standort…").font(.caption).foregroundStyle(.secondary)
}
}
TextField("Ort überschreiben (optional)", text: $locationName)
}
} header: {
Text("Standort")
} footer: {
Text("Der Standort wird zusammen mit dem Eintrag gespeichert — banyaro.app rendert daraus deine POI-Karte.")
.font(.caption2)
}
Section {
PhotosPicker(
selection: $photoSelection,
maxSelectionCount: 6,
matching: .images
) {
Label(photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern", systemImage: "photo.badge.plus")
}
if !photoData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(photoData.enumerated()), id: \.offset) { _, d in
if let img = UIImage(data: d) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
} header: {
Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))")
}
if let errorMessage {
Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) }
}
}
.navigationTitle("Neuer Eintrag")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
.disabled(saveState != .idle)
}
ToolbarItem(placement: .confirmationAction) {
saveToolbarItem
}
}
.onChange(of: photoSelection) { _, items in
Task { await loadPhotos(from: items) }
}
.task { location.request() }
.onChange(of: location.coordinate?.latitude) { _, _ in
guard let coord = location.coordinate, locationName.isEmpty else { return }
Task { await reverseGeocode(coord) }
}
.interactiveDismissDisabled(saveState != .idle)
}
}
private func reverseGeocode(_ coord: CLLocationCoordinate2D) async {
isReverseGeocoding = true
defer { isReverseGeocoding = false }
let geocoder = CLGeocoder()
let loc = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
do {
let placemarks = try await geocoder.reverseGeocodeLocation(loc, preferredLocale: Locale(identifier: "de_DE"))
guard let p = placemarks.first else { return }
// Sample: "Café Kaiser, Berlin" name first, then locality fallback
if let name = p.name, !name.isEmpty {
locationName = name
return
}
var parts: [String] = []
if let street = p.thoroughfare { parts.append(street) }
if let city = p.locality { parts.append(city) }
if !parts.isEmpty { locationName = parts.joined(separator: ", ") }
} catch {
print("Reverse geocode failed: \(error)")
}
}
@ViewBuilder
private var saveToolbarItem: some View {
switch saveState {
case .idle:
Button("Sichern") { Task { await save() } }
.disabled(titel.trimmingCharacters(in: .whitespaces).isEmpty &&
text.trimmingCharacters(in: .whitespaces).isEmpty)
case .savingEntry:
ProgressView()
case .uploadingMedia(let done, let total):
Text("\(done)/\(total)").font(.caption.monospacedDigit())
}
}
private func loadPhotos(from items: [PhotosPickerItem]) async {
var loaded: [Data] = []
for item in items {
if let d = try? await item.loadTransferable(type: Data.self) {
loaded.append(d)
}
}
photoData = loaded
}
private func save() async {
errorMessage = nil
saveState = .savingEntry
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let tags = tagsInput
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let coord = attachLocation ? location.coordinate : nil
let body = DiaryCreateBody(
datum: formatter.string(from: date),
typ: isMilestone ? "meilenstein" : "eintrag",
titel: titel.trimmingCharacters(in: .whitespaces).isEmpty ? nil : titel.trimmingCharacters(in: .whitespaces),
text: text.trimmingCharacters(in: .whitespaces).isEmpty ? nil : text.trimmingCharacters(in: .whitespacesAndNewlines),
tags: tags.isEmpty ? nil : tags,
gpsLat: coord?.latitude,
gpsLon: coord?.longitude,
locationName: locationName.trimmingCharacters(in: .whitespaces).isEmpty ? nil : locationName,
isMilestone: isMilestone
)
let entry: DiaryEntry
do {
entry = try await APIClient.shared.post("/api/dogs/\(dogId)/diary", body: body)
} catch {
errorMessage = error.localizedDescription
saveState = .idle
return
}
if !photoData.isEmpty {
for (i, raw) in photoData.enumerated() {
saveState = .uploadingMedia(done: i, total: photoData.count)
let resized = ImageResize.resizedJPEG(from: raw)
do {
_ = try await APIClient.shared.uploadFile(
"/api/dogs/\(dogId)/diary/\(entry.id)/media",
filename: "media_\(i + 1).jpg",
data: resized
)
} catch {
errorMessage = "Eintrag gespeichert, Foto \(i + 1) fehlgeschlagen: \(error.localizedDescription)"
await onSaved()
saveState = .idle
return
}
}
}
await onSaved()
dismiss()
}
}