banyaro-ios/BanYaroGo/Views/GassiTreffenList.swift
rene 09a90f7325 Gassi-Treffen-Liste: Karte oben mit Treffpunkt-Pins (analog Verlorene/Giftköder)
- Map(position:) mit UserAnnotation + Pin pro Treffen (pawprint.circle.fill)
- Tap auf Pin öffnet das Detail-Sheet (mit 'Schließen'-Button)
- Re-Center-Button oben rechts, .onAppear zentriert auf User
- Drunter wie gehabt: Liste mit NavigationLink, Empty-State, Spinner
2026-05-30 14:21:38 +02:00

210 lines
7.4 KiB
Swift

import SwiftUI
import MapKit
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
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var tappedMeeting: WalkMeeting?
var body: some View {
Group {
if let coord = location.coordinate {
content(at: coord)
} else if location.error != nil {
ContentUnavailableView(
"Kein Standort",
systemImage: "location.slash",
description: Text(location.error ?? "")
)
} else {
ProgressView("Hole Standort…")
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showAdd = true
} label: {
Label("Planen", systemImage: "plus")
}
}
}
.sheet(isPresented: $showAdd) {
AddWalkSheet(coord: location.coordinate) { await load() }
}
.sheet(item: $tappedMeeting) { meeting in
NavigationStack {
GassiTreffenDetail(meetingId: meeting.id, fallbackTitle: meeting.titel) {
await load()
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Schließen") { tappedMeeting = nil }
}
}
}
}
.task {
location.request()
await load()
}
.onChange(of: location.coordinate?.latitude) { _, _ in
if let c = location.coordinate { centerOn(c) }
Task { await load() }
}
.refreshable { await load() }
}
private func content(at coord: CLLocationCoordinate2D) -> some View {
VStack(spacing: 0) {
Map(position: $cameraPosition) {
UserAnnotation()
ForEach(meetings) { meeting in
Annotation(meeting.titel,
coordinate: CLLocationCoordinate2D(latitude: meeting.lat, longitude: meeting.lon)
) {
Button {
tappedMeeting = meeting
} label: {
Image(systemName: "pawprint.circle.fill")
.font(.title2)
.foregroundStyle(.white, Color.accentColor)
.background(.white, in: Circle())
.shadow(radius: 2)
}
.buttonStyle(.plain)
}
}
}
.frame(height: 260)
.ignoresSafeArea(edges: .top)
.onAppear { centerOn(coord) }
.overlay(alignment: .topTrailing) {
Button {
centerOn(coord)
} label: {
Image(systemName: "location.fill")
.font(.callout.bold())
.foregroundStyle(Color.accentColor)
.padding(10)
.background(.thinMaterial, in: Circle())
.shadow(radius: 2)
}
.padding(.top, 60)
.padding(.trailing, 12)
}
if isLoading && meetings.isEmpty {
ProgressView().padding(.top, 30)
Spacer()
} else if let errorMessage, meetings.isEmpty {
ContentUnavailableView(
"Konnte nicht laden",
systemImage: "wifi.slash",
description: Text(errorMessage)
)
.padding(.top, 30)
Spacer()
} 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.")
)
.padding(.top, 30)
Spacer()
} else {
List(meetings) { meeting in
NavigationLink {
GassiTreffenDetail(meetingId: meeting.id, fallbackTitle: meeting.titel) {
await load()
}
} label: {
TreffenRow(meeting: meeting)
}
}
.listStyle(.plain)
}
}
}
private func centerOn(_ coord: CLLocationCoordinate2D) {
withAnimation(.easeInOut(duration: 0.4)) {
cameraPosition = .region(MKCoordinateRegion(
center: coord,
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
))
}
}
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
}
}