- 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.
254 lines
10 KiB
Swift
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()
|
|
}
|
|
}
|