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).
292 lines
10 KiB
Swift
292 lines
10 KiB
Swift
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
|
|
}
|
|
}
|