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.
129 lines
4.3 KiB
Swift
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
|
|
}
|
|
}
|