banyaro-ios/BanYaroGo/Views/GassiTreffenList.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

129 lines
4.3 KiB
Swift

import SwiftUI
struct GassiTreffenList: View {
@State private var location = OneShotLocation()
@State private var meetings: [WalkMeeting] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showAdd = false
var body: some View {
content
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showAdd = true
} label: {
Label("Planen", systemImage: "plus")
}
}
}
.sheet(isPresented: $showAdd) {
AddWalkSheet(coord: location.coordinate) { await load() }
}
.task {
location.request()
await load()
}
.onChange(of: location.coordinate?.latitude) { _, _ in
Task { await load() }
}
.refreshable { await load() }
}
@ViewBuilder
private var content: some View {
if isLoading && meetings.isEmpty {
ProgressView()
} else if let errorMessage, meetings.isEmpty {
ContentUnavailableView(
"Konnte nicht laden",
systemImage: "wifi.slash",
description: Text(errorMessage)
)
} else if meetings.isEmpty {
ContentUnavailableView(
"Noch keine Treffen",
systemImage: "person.2",
description: Text("In 20 km Umkreis sind aktuell keine Gassi-Treffen geplant. Tippe oben rechts auf +, um eins zu planen.")
)
} else {
List(meetings) { meeting in
NavigationLink {
GassiTreffenDetail(meetingId: meeting.id, fallbackTitle: meeting.titel) {
await load()
}
} label: {
TreffenRow(meeting: meeting)
}
}
}
}
private func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
var path = "/api/walks?radius=20000"
if let coord = location.coordinate {
path = "/api/walks?lat=\(coord.latitude)&lon=\(coord.longitude)&radius=20000"
}
meetings = try await APIClient.shared.get(path)
} catch {
errorMessage = error.localizedDescription
}
}
}
private struct TreffenRow: View {
let meeting: WalkMeeting
var body: some View {
HStack(spacing: 12) {
Image(systemName: "pawprint.circle.fill")
.font(.title)
.foregroundStyle(.white, Color.accentColor)
.frame(width: 40)
VStack(alignment: .leading, spacing: 4) {
Text(meeting.titel).font(.headline)
HStack(spacing: 6) {
Image(systemName: "calendar")
Text(formatDate(meeting.datum))
Text(meeting.uhrzeit)
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
if let ort = meeting.ortName, !ort.isEmpty {
HStack(spacing: 4) {
Image(systemName: "mappin.and.ellipse")
Text(ort)
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(meeting.teilnehmerCount ?? 0)/\(meeting.maxTeilnehmer)")
.font(.caption.bold().monospacedDigit())
Text("Teilnehmer").font(.caption2).foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
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.dateFormat = "EEE d. MMM"
return out.string(from: d)
}
return s
}
}