banyaro-ios/BanYaroGo/Views/GassiTreffenDetail.swift
rene b49883ca79 Gassi: Tabs Treffen + Stamm-Gassis wie in der PWA
In der PWA ist die Seite 'Gassi-Treffen' mit drei Tabs:
- Treffen (walks.py — sich verabreden)
- Challenge (Monatsfoto)
- Stamm-Gassis (gassi_zeiten.py — regelmäßige Runden)

Mein bisheriger Mehr-Eintrag hieß 'Stamm-Gassi-Zeiten' und zeigte nur die
Stamm-Gassi-Funktion isoliert — das stimmte nicht mit der PWA überein.

Neu:
- GassiView mit Segmented Picker (Treffen / Stamm-Gassis)
- GassiTreffenList: GET /api/walks?lat&lon&radius=20000, Liste mit Datum,
  Uhrzeit, Ort, Teilnehmer-Zahl
- GassiTreffenDetail: Karte mit Pin, Stats, Beitreten/Verlassen
  (POST/DELETE /api/walks/{id}/join), Owner-Check
- AddWalkSheet: Titel, Datum, Uhrzeit, Treffpunkt-Name, Max-Teilnehmer,
  Beschreibung — POST /api/walks
- StammGassisList = bisherige GassiZeitenView umbenannt + Nav-Title raus
  (wird vom GassiView vergeben)

Im Mehr-Tab heißt der Link jetzt 'Gassi-Treffen' (pawprint-Icon) statt
'Stamm-Gassi-Zeiten' (alarm-Icon).

DTOs: WalkMeeting, WalkCreateBody, WalkJoinBody.
2026-05-30 14:02:42 +02:00

172 lines
5.7 KiB
Swift

import SwiftUI
import MapKit
struct GassiTreffenDetail: View {
let meetingId: Int
let fallbackTitle: String
let onChange: () async -> Void
@Environment(AuthSession.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var meeting: WalkMeeting?
@State private var isLoading = false
@State private var errorMessage: String?
@State private var isMutating = false
@State private var dogs: [Dog] = []
@State private var hasJoined: Bool? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let meeting {
detailContent(meeting)
} else if isLoading {
ProgressView().padding(.top, 60)
} else if let errorMessage {
ContentUnavailableView(
"Fehler",
systemImage: "exclamationmark.triangle",
description: Text(errorMessage)
)
.padding(.top, 60)
}
}
.padding()
}
.navigationTitle(meeting?.titel ?? fallbackTitle)
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
}
@ViewBuilder
private func detailContent(_ m: WalkMeeting) -> some View {
Map(initialPosition: .region(MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: m.lat, longitude: m.lon),
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
))) {
Annotation(m.titel, coordinate: CLLocationCoordinate2D(latitude: m.lat, longitude: m.lon)) {
Image(systemName: "pawprint.circle.fill")
.font(.title)
.foregroundStyle(.white, Color.accentColor)
.background(.white, in: Circle())
}
}
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false)
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "calendar")
Text(formatDate(m.datum))
Text(m.uhrzeit).monospacedDigit()
}
.font(.headline)
if let ort = m.ortName, !ort.isEmpty {
Label(ort, systemImage: "mappin.and.ellipse")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack {
Label("\(m.teilnehmerCount ?? 0) / \(m.maxTeilnehmer) Teilnehmer", systemImage: "person.2.fill")
if let veranstalter = m.veranstalterName {
Spacer()
Text("von \(veranstalter)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.font(.subheadline)
.padding(.top, 4)
}
if let beschreibung = m.beschreibung, !beschreibung.isEmpty {
Text(beschreibung)
.font(.body)
.padding(.top, 4)
}
if !isOwn(m) {
joinButton(m)
}
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
private func joinButton(_ m: WalkMeeting) -> some View {
Button {
Task { await toggleJoin() }
} label: {
HStack {
Image(systemName: (hasJoined == true) ? "person.crop.circle.badge.minus" : "person.crop.circle.badge.plus")
Text((hasJoined == true) ? "Verlassen" : "Beitreten").bold()
}
.frame(maxWidth: .infinity, minHeight: 50)
}
.background((hasJoined == true) ? Color.red : Color.accentColor, in: Capsule())
.foregroundStyle(.white)
.disabled(isMutating)
}
private func isOwn(_ m: WalkMeeting) -> Bool {
m.userId == auth.profile?.id
}
private func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
meeting = try await APIClient.shared.get("/api/walks/\(meetingId)")
if dogs.isEmpty {
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
}
// We don't have a clean "have I joined" endpoint, so we treat hasJoined
// as nil button shows "Beitreten". After successful join we set true,
// after successful leave we set false.
} catch {
errorMessage = error.localizedDescription
}
}
private func toggleJoin() async {
isMutating = true
errorMessage = nil
defer { isMutating = false }
do {
if hasJoined == true {
try await APIClient.shared.delete("/api/walks/\(meetingId)/join")
hasJoined = false
} else {
let body = WalkJoinBody(dogIds: dogs.map { $0.id })
let _: WalkMeeting = try await APIClient.shared.post("/api/walks/\(meetingId)/join", body: body)
hasJoined = true
}
await onChange()
await load()
} catch {
errorMessage = error.localizedDescription
}
}
private func formatDate(_ s: String) -> String {
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.dateFormat = "yyyy-MM-dd"
if let d = parser.date(from: String(s.prefix(10))) {
let out = DateFormatter()
out.locale = Locale(identifier: "de_DE")
out.dateStyle = .full
return out.string(from: d)
}
return s
}
}