import SwiftUI import MapKit struct GassiTreffenList: View { @State private var location = OneShotLocation() @State private var meetings: [WalkMeeting] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var showAdd = false @State private var cameraPosition: MapCameraPosition = .automatic @State private var tappedMeeting: WalkMeeting? var body: some View { Group { if let coord = location.coordinate { content(at: coord) } else if location.error != nil { ContentUnavailableView( "Kein Standort", systemImage: "location.slash", description: Text(location.error ?? "") ) } else { ProgressView("Hole Standort…") } } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showAdd = true } label: { Label("Planen", systemImage: "plus") } } } .sheet(isPresented: $showAdd) { AddWalkSheet(coord: location.coordinate) { await load() } } .sheet(item: $tappedMeeting) { meeting in NavigationStack { GassiTreffenDetail(meetingId: meeting.id, fallbackTitle: meeting.titel) { await load() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Schließen") { tappedMeeting = nil } } } } } .task { location.request() await load() } .onChange(of: location.coordinate?.latitude) { _, _ in if let c = location.coordinate { centerOn(c) } Task { await load() } } .refreshable { await load() } } private func content(at coord: CLLocationCoordinate2D) -> some View { VStack(spacing: 0) { Map(position: $cameraPosition) { UserAnnotation() ForEach(meetings) { meeting in Annotation(meeting.titel, coordinate: CLLocationCoordinate2D(latitude: meeting.lat, longitude: meeting.lon) ) { Button { tappedMeeting = meeting } label: { Image(systemName: "pawprint.circle.fill") .font(.title2) .foregroundStyle(.white, Color.accentColor) .background(.white, in: Circle()) .shadow(radius: 2) } .buttonStyle(.plain) } } } .frame(height: 260) .ignoresSafeArea(edges: .top) .onAppear { centerOn(coord) } .overlay(alignment: .topTrailing) { Button { centerOn(coord) } label: { Image(systemName: "location.fill") .font(.callout.bold()) .foregroundStyle(Color.accentColor) .padding(10) .background(.thinMaterial, in: Circle()) .shadow(radius: 2) } .padding(.top, 60) .padding(.trailing, 12) } if isLoading && meetings.isEmpty { ProgressView().padding(.top, 30) Spacer() } else if let errorMessage, meetings.isEmpty { ContentUnavailableView( "Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage) ) .padding(.top, 30) Spacer() } else if meetings.isEmpty { ContentUnavailableView( "Noch keine Treffen", systemImage: "person.2", description: Text("In 20 km Umkreis sind aktuell keine Gassi-Treffen geplant. Tippe oben rechts auf +, um eins zu planen.") ) .padding(.top, 30) Spacer() } else { List(meetings) { meeting in NavigationLink { GassiTreffenDetail(meetingId: meeting.id, fallbackTitle: meeting.titel) { await load() } } label: { TreffenRow(meeting: meeting) } } .listStyle(.plain) } } } private func centerOn(_ coord: CLLocationCoordinate2D) { withAnimation(.easeInOut(duration: 0.4)) { cameraPosition = .region(MKCoordinateRegion( center: coord, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1) )) } } private func load() async { isLoading = true errorMessage = nil defer { isLoading = false } do { var path = "/api/walks?radius=20000" if let coord = location.coordinate { path = "/api/walks?lat=\(coord.latitude)&lon=\(coord.longitude)&radius=20000" } meetings = try await APIClient.shared.get(path) } catch { errorMessage = error.localizedDescription } } } private struct TreffenRow: View { let meeting: WalkMeeting var body: some View { HStack(spacing: 12) { Image(systemName: "pawprint.circle.fill") .font(.title) .foregroundStyle(.white, Color.accentColor) .frame(width: 40) VStack(alignment: .leading, spacing: 4) { Text(meeting.titel).font(.headline) HStack(spacing: 6) { Image(systemName: "calendar") Text(formatDate(meeting.datum)) Text(meeting.uhrzeit) } .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) if let ort = meeting.ortName, !ort.isEmpty { HStack(spacing: 4) { Image(systemName: "mappin.and.ellipse") Text(ort) } .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("\(meeting.teilnehmerCount ?? 0)/\(meeting.maxTeilnehmer)") .font(.caption.bold().monospacedDigit()) Text("Teilnehmer").font(.caption2).foregroundStyle(.secondary) } } .padding(.vertical, 4) } 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.dateFormat = "EEE d. MMM" return out.string(from: d) } return s } }