Gassi-Treffen-Detail: Fokus auf 'hinfinden'

Detail-Karte zeigt jetzt Treffpunkt + User-Position via UserAnnotation,
darunter eine Navigation-Karte mit:
- Ortsname
- Luftlinien-Distanz vom aktuellen Standort
- 'Route'-Button öffnet Apple Maps (MKMapItem.openInMaps) — User wählt
  dort Walking/Driving

Plus Teilnehmer-Sektion mit GET /api/walks/{id}/participants:
- Liste der Zugesagten ('yes'-RSVP) mit Namen + Hunden
- 'Noch niemand zugesagt'-Hinweis bei leerer Liste
- myRsvp-Status für korrekte Join-Button-Anzeige
- isOwn → 'Dein Treffen'-Badge statt Beitreten-Button

Footer-Hinweis: 'Fotos vom Treffen kannst du später in der banyaro.app
teilen' — Foto-Funktion bewusst PWA-only (User-Wunsch).
This commit is contained in:
rene 2026-05-30 14:16:43 +02:00
parent b49883ca79
commit 00dba257b5
2 changed files with 189 additions and 55 deletions

View file

@ -168,6 +168,20 @@ struct WalkJoinBody: Encodable {
let dogIds: [Int]
}
struct WalkParticipantsResponse: Decodable {
let invitations: [WalkInvitation]
let myRsvp: String?
let isOrganizer: Bool
}
struct WalkInvitation: Decodable, Identifiable {
let userId: Int
let status: String?
let userName: String?
let hunde: String?
var id: Int { userId }
}
struct GassiZeitCreateBody: Encodable {
let dogId: Int?
let wochentage: [String]

View file

@ -1,5 +1,6 @@
import SwiftUI
import MapKit
import CoreLocation
struct GassiTreffenDetail: View {
let meetingId: Int
@ -7,15 +8,14 @@ struct GassiTreffenDetail: View {
let onChange: () async -> Void
@Environment(AuthSession.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var meeting: WalkMeeting?
@State private var participants: WalkParticipantsResponse?
@State private var location = OneShotLocation()
@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 {
@ -37,63 +37,54 @@ struct GassiTreffenDetail: View {
}
.navigationTitle(meeting?.titel ?? fallbackTitle)
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
.task {
location.request()
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)
treffpunktCard(m)
navigationCard(m)
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
Image(systemName: "calendar")
.foregroundStyle(Color.accentColor)
Text(formatDate(m.datum))
Text(m.uhrzeit).monospacedDigit()
.font(.headline)
Spacer()
Text(m.uhrzeit)
.font(.headline.monospacedDigit())
}
.font(.headline)
if let ort = m.ortName, !ort.isEmpty {
Label(ort, systemImage: "mappin.and.ellipse")
if let veranstalter = m.veranstalterName {
Label("von \(veranstalter)", systemImage: "person.circle.fill")
.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)
}
if let beschreibung = m.beschreibung, !beschreibung.isEmpty {
Text(beschreibung).font(.body).padding(.top, 4)
}
.font(.subheadline)
.padding(.top, 4)
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
if let beschreibung = m.beschreibung, !beschreibung.isEmpty {
Text(beschreibung)
.font(.body)
.padding(.top, 4)
}
teilnehmerSection(m)
if !isOwn(m) {
joinButton(m)
} else {
ownerBadge
}
Text("Fotos vom Treffen kannst du später in der banyaro.app teilen.")
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity)
if let errorMessage {
Text(errorMessage)
.font(.footnote)
@ -101,39 +92,170 @@ struct GassiTreffenDetail: View {
}
}
// MARK: - Treffpunkt card
private func treffpunktCard(_ m: WalkMeeting) -> some View {
Map(initialPosition: .region(MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: m.lat, longitude: m.lon),
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
))) {
UserAnnotation()
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())
.shadow(radius: 2)
}
}
.frame(height: 240)
.clipShape(RoundedRectangle(cornerRadius: 16))
.allowsHitTesting(false)
}
// MARK: - Navigation row
private func navigationCard(_ m: WalkMeeting) -> some View {
HStack(spacing: 12) {
Image(systemName: "mappin.and.ellipse")
.font(.title2)
.foregroundStyle(Color.accentColor)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(m.ortName ?? "Treffpunkt")
.font(.subheadline.bold())
if let dist = distanceLabel(to: m) {
Text(dist)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
openInMaps(m)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.turn.up.right.diamond.fill")
Text("Route").bold()
}
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.accentColor, in: Capsule())
.foregroundStyle(.white)
}
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Teilnehmer
@ViewBuilder
private func teilnehmerSection(_ m: WalkMeeting) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Teilnehmer", systemImage: "person.2.fill")
.font(.headline)
Spacer()
Text("\(m.teilnehmerCount ?? 0) / \(m.maxTeilnehmer)")
.font(.subheadline.bold().monospacedDigit())
.foregroundStyle(.secondary)
}
if let yes = participants?.invitations.filter({ $0.status == "yes" }), !yes.isEmpty {
ForEach(yes) { p in
participantRow(p)
}
} else {
Text("Noch niemand zugesagt — sei der/die Erste.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
private func participantRow(_ p: WalkInvitation) -> some View {
HStack(spacing: 10) {
Image(systemName: "person.circle.fill")
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(p.userName ?? "Teilnehmer").font(.subheadline.bold())
if let hunde = p.hunde, !hunde.isEmpty {
Text(hunde).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
}
}
// MARK: - Join / leave
private var hasJoined: Bool {
participants?.myRsvp == "yes"
}
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()
Image(systemName: hasJoined ? "person.crop.circle.badge.minus" : "person.crop.circle.badge.plus")
Text(hasJoined ? "Doch nicht teilnehmen" : "Ich bin dabei").bold()
}
.frame(maxWidth: .infinity, minHeight: 50)
}
.background((hasJoined == true) ? Color.red : Color.accentColor, in: Capsule())
.background(hasJoined ? Color.red : Color.accentColor, in: Capsule())
.foregroundStyle(.white)
.disabled(isMutating)
}
private var ownerBadge: some View {
Label("Dein Treffen", systemImage: "person.badge.shield.checkmark.fill")
.font(.subheadline.bold())
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(Color.accentColor.opacity(0.15), in: Capsule())
.foregroundStyle(Color.accentColor)
}
// MARK: - Helpers
private func isOwn(_ m: WalkMeeting) -> Bool {
m.userId == auth.profile?.id
}
private func distanceLabel(to m: WalkMeeting) -> String? {
guard let me = location.coordinate else { return nil }
let here = CLLocation(latitude: me.latitude, longitude: me.longitude)
let there = CLLocation(latitude: m.lat, longitude: m.lon)
let meters = there.distance(from: here)
if meters < 1000 {
return "\(Int(meters)) m entfernt"
}
return String(format: "%.1f km entfernt", meters / 1000)
}
private func openInMaps(_ m: WalkMeeting) {
let placemark = MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: m.lat, longitude: m.lon))
let item = MKMapItem(placemark: placemark)
item.name = m.ortName?.isEmpty == false ? m.ortName : m.titel
// Default mode lets the user pick walking/driving in Apple Maps
item.openInMaps(launchOptions: nil)
}
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
async let detail: WalkMeeting? = try? APIClient.shared.get("/api/walks/\(meetingId)")
async let parts: WalkParticipantsResponse? = try? APIClient.shared.get("/api/walks/\(meetingId)/participants")
meeting = await detail
participants = await parts
if dogs.isEmpty {
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
}
}
@ -142,13 +264,11 @@ struct GassiTreffenDetail: View {
errorMessage = nil
defer { isMutating = false }
do {
if hasJoined == true {
if hasJoined {
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()