import SwiftUI import MapKit import CoreLocation struct GassiTreffenDetail: View { let meetingId: Int let fallbackTitle: String let onChange: () async -> Void @Environment(AuthSession.self) private var auth @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] = [] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if let meeting { detailContent(meeting) } else if isLoading { ProgressView().padding(.top, 60) } else if let errorMessage { ContentUnavailableView( "Fehler", systemImage: "exclamationmark.triangle", description: Text(errorMessage) ) .padding(.top, 60) } } .padding() } .navigationTitle(meeting?.titel ?? fallbackTitle) .navigationBarTitleDisplayMode(.inline) .task { location.request() await load() } } @ViewBuilder private func detailContent(_ m: WalkMeeting) -> some View { treffpunktCard(m) navigationCard(m) VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { Image(systemName: "calendar") .foregroundStyle(Color.accentColor) Text(formatDate(m.datum)) .font(.headline) Spacer() Text(m.uhrzeit) .font(.headline.monospacedDigit()) } if let veranstalter = m.veranstalterName { Label("von \(veranstalter)", systemImage: "person.circle.fill") .font(.subheadline) .foregroundStyle(.secondary) } if let beschreibung = m.beschreibung, !beschreibung.isEmpty { Text(beschreibung).font(.body).padding(.top, 4) } } .padding(14) .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) 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) .foregroundStyle(.red) } } // 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 ? "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 ? 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 } 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")) ?? [] } } private func toggleJoin() async { isMutating = true errorMessage = nil defer { isMutating = false } do { if hasJoined { try await APIClient.shared.delete("/api/walks/\(meetingId)/join") } else { let body = WalkJoinBody(dogIds: dogs.map { $0.id }) let _: WalkMeeting = try await APIClient.shared.post("/api/walks/\(meetingId)/join", body: body) } await onChange() await load() } catch { errorMessage = error.localizedDescription } } 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.dateStyle = .full return out.string(from: d) } return s } }