From 00dba257b5812d843e51d2deef3b6e05da3f6b60 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 30 May 2026 14:16:43 +0200 Subject: [PATCH] Gassi-Treffen-Detail: Fokus auf 'hinfinden' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- BanYaroGo/API/DTOs.swift | 14 ++ BanYaroGo/Views/GassiTreffenDetail.swift | 230 +++++++++++++++++------ 2 files changed, 189 insertions(+), 55 deletions(-) diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift index c09a1ca..83a9c65 100644 --- a/BanYaroGo/API/DTOs.swift +++ b/BanYaroGo/API/DTOs.swift @@ -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] diff --git a/BanYaroGo/Views/GassiTreffenDetail.swift b/BanYaroGo/Views/GassiTreffenDetail.swift index 733d55e..fa5b13d 100644 --- a/BanYaroGo/Views/GassiTreffenDetail.swift +++ b/BanYaroGo/Views/GassiTreffenDetail.swift @@ -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()