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]
|
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 {
|
struct GassiZeitCreateBody: Encodable {
|
||||||
let dogId: Int?
|
let dogId: Int?
|
||||||
let wochentage: [String]
|
let wochentage: [String]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MapKit
|
import MapKit
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
struct GassiTreffenDetail: View {
|
struct GassiTreffenDetail: View {
|
||||||
let meetingId: Int
|
let meetingId: Int
|
||||||
|
|
@ -7,15 +8,14 @@ struct GassiTreffenDetail: View {
|
||||||
let onChange: () async -> Void
|
let onChange: () async -> Void
|
||||||
|
|
||||||
@Environment(AuthSession.self) private var auth
|
@Environment(AuthSession.self) private var auth
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
@State private var meeting: WalkMeeting?
|
@State private var meeting: WalkMeeting?
|
||||||
|
@State private var participants: WalkParticipantsResponse?
|
||||||
|
@State private var location = OneShotLocation()
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var isMutating = false
|
@State private var isMutating = false
|
||||||
|
|
||||||
@State private var dogs: [Dog] = []
|
@State private var dogs: [Dog] = []
|
||||||
@State private var hasJoined: Bool? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -37,63 +37,54 @@ struct GassiTreffenDetail: View {
|
||||||
}
|
}
|
||||||
.navigationTitle(meeting?.titel ?? fallbackTitle)
|
.navigationTitle(meeting?.titel ?? fallbackTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task { await load() }
|
.task {
|
||||||
|
location.request()
|
||||||
|
await load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func detailContent(_ m: WalkMeeting) -> some View {
|
private func detailContent(_ m: WalkMeeting) -> some View {
|
||||||
Map(initialPosition: .region(MKCoordinateRegion(
|
treffpunktCard(m)
|
||||||
center: CLLocationCoordinate2D(latitude: m.lat, longitude: m.lon),
|
navigationCard(m)
|
||||||
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)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Image(systemName: "calendar")
|
Image(systemName: "calendar")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
Text(formatDate(m.datum))
|
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 {
|
if let veranstalter = m.veranstalterName {
|
||||||
Label(ort, systemImage: "mappin.and.ellipse")
|
Label("von \(veranstalter)", systemImage: "person.circle.fill")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
if let beschreibung = m.beschreibung, !beschreibung.isEmpty {
|
||||||
Label("\(m.teilnehmerCount ?? 0) / \(m.maxTeilnehmer) Teilnehmer", systemImage: "person.2.fill")
|
Text(beschreibung).font(.body).padding(.top, 4)
|
||||||
if let veranstalter = m.veranstalterName {
|
|
||||||
Spacer()
|
|
||||||
Text("von \(veranstalter)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.font(.subheadline)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
if let beschreibung = m.beschreibung, !beschreibung.isEmpty {
|
teilnehmerSection(m)
|
||||||
Text(beschreibung)
|
|
||||||
.font(.body)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isOwn(m) {
|
if !isOwn(m) {
|
||||||
joinButton(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 {
|
if let errorMessage {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
.font(.footnote)
|
.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 {
|
private func joinButton(_ m: WalkMeeting) -> some View {
|
||||||
Button {
|
Button {
|
||||||
Task { await toggleJoin() }
|
Task { await toggleJoin() }
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: (hasJoined == true) ? "person.crop.circle.badge.minus" : "person.crop.circle.badge.plus")
|
Image(systemName: hasJoined ? "person.crop.circle.badge.minus" : "person.crop.circle.badge.plus")
|
||||||
Text((hasJoined == true) ? "Verlassen" : "Beitreten").bold()
|
Text(hasJoined ? "Doch nicht teilnehmen" : "Ich bin dabei").bold()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 50)
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
}
|
}
|
||||||
.background((hasJoined == true) ? Color.red : Color.accentColor, in: Capsule())
|
.background(hasJoined ? Color.red : Color.accentColor, in: Capsule())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.disabled(isMutating)
|
.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 {
|
private func isOwn(_ m: WalkMeeting) -> Bool {
|
||||||
m.userId == auth.profile?.id
|
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 {
|
private func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
async let detail: WalkMeeting? = try? APIClient.shared.get("/api/walks/\(meetingId)")
|
||||||
meeting = try await APIClient.shared.get("/api/walks/\(meetingId)")
|
async let parts: WalkParticipantsResponse? = try? APIClient.shared.get("/api/walks/\(meetingId)/participants")
|
||||||
if dogs.isEmpty {
|
meeting = await detail
|
||||||
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
|
participants = await parts
|
||||||
}
|
if dogs.isEmpty {
|
||||||
// We don't have a clean "have I joined" endpoint, so we treat hasJoined
|
dogs = (try? await APIClient.shared.get("/api/dogs")) ?? []
|
||||||
// as nil → button shows "Beitreten". After successful join we set true,
|
|
||||||
// after successful leave we set false.
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,13 +264,11 @@ struct GassiTreffenDetail: View {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isMutating = false }
|
defer { isMutating = false }
|
||||||
do {
|
do {
|
||||||
if hasJoined == true {
|
if hasJoined {
|
||||||
try await APIClient.shared.delete("/api/walks/\(meetingId)/join")
|
try await APIClient.shared.delete("/api/walks/\(meetingId)/join")
|
||||||
hasJoined = false
|
|
||||||
} else {
|
} else {
|
||||||
let body = WalkJoinBody(dogIds: dogs.map { $0.id })
|
let body = WalkJoinBody(dogIds: dogs.map { $0.id })
|
||||||
let _: WalkMeeting = try await APIClient.shared.post("/api/walks/\(meetingId)/join", body: body)
|
let _: WalkMeeting = try await APIClient.shared.post("/api/walks/\(meetingId)/join", body: body)
|
||||||
hasJoined = true
|
|
||||||
}
|
}
|
||||||
await onChange()
|
await onChange()
|
||||||
await load()
|
await load()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue