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.
172 lines
5.7 KiB
Swift
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
|
|
}
|
|
}
|