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:
parent
b49883ca79
commit
00dba257b5
2 changed files with 189 additions and 55 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue