From b49883ca7979b0391b073e7054ae0d3ea73b8cca Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 30 May 2026 14:02:42 +0200 Subject: [PATCH] Gassi: Tabs Treffen + Stamm-Gassis wie in der PWA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In der PWA ist die Seite 'Gassi-Treffen' mit drei Tabs: - Treffen (walks.py — sich verabreden) - Challenge (Monatsfoto) - Stamm-Gassis (gassi_zeiten.py — regelmäßige Runden) Mein bisheriger Mehr-Eintrag hieß 'Stamm-Gassi-Zeiten' und zeigte nur die Stamm-Gassi-Funktion isoliert — das stimmte nicht mit der PWA überein. Neu: - GassiView mit Segmented Picker (Treffen / Stamm-Gassis) - GassiTreffenList: GET /api/walks?lat&lon&radius=20000, Liste mit Datum, Uhrzeit, Ort, Teilnehmer-Zahl - GassiTreffenDetail: Karte mit Pin, Stats, Beitreten/Verlassen (POST/DELETE /api/walks/{id}/join), Owner-Check - AddWalkSheet: Titel, Datum, Uhrzeit, Treffpunkt-Name, Max-Teilnehmer, Beschreibung — POST /api/walks - StammGassisList = bisherige GassiZeitenView umbenannt + Nav-Title raus (wird vom GassiView vergeben) Im Mehr-Tab heißt der Link jetzt 'Gassi-Treffen' (pawprint-Icon) statt 'Stamm-Gassi-Zeiten' (alarm-Icon). DTOs: WalkMeeting, WalkCreateBody, WalkJoinBody. --- .../xcshareddata/xcschemes/BanYaroGo.xcscheme | 79 ++++++++ .../BanYaroGoWidgetExtension.xcscheme | 125 +++++++++++++ BanYaroGo/API/DTOs.swift | 33 ++++ BanYaroGo/Views/AddWalkSheet.swift | 107 +++++++++++ BanYaroGo/Views/GassiTreffenDetail.swift | 172 ++++++++++++++++++ BanYaroGo/Views/GassiTreffenList.swift | 129 +++++++++++++ BanYaroGo/Views/GassiView.swift | 36 ++++ BanYaroGo/Views/GassiZeitenView.swift | 4 +- BanYaroGo/Views/SettingsView.swift | 4 +- 9 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGo.xcscheme create mode 100644 BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGoWidgetExtension.xcscheme create mode 100644 BanYaroGo/Views/AddWalkSheet.swift create mode 100644 BanYaroGo/Views/GassiTreffenDetail.swift create mode 100644 BanYaroGo/Views/GassiTreffenList.swift create mode 100644 BanYaroGo/Views/GassiView.swift diff --git a/BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGo.xcscheme b/BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGo.xcscheme new file mode 100644 index 0000000..617950f --- /dev/null +++ b/BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGo.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGoWidgetExtension.xcscheme b/BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGoWidgetExtension.xcscheme new file mode 100644 index 0000000..0e99038 --- /dev/null +++ b/BanYaroGo.xcodeproj/xcshareddata/xcschemes/BanYaroGoWidgetExtension.xcscheme @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift index 8f2c6a8..c09a1ca 100644 --- a/BanYaroGo/API/DTOs.swift +++ b/BanYaroGo/API/DTOs.swift @@ -135,6 +135,39 @@ struct GassiZeit: Decodable, Identifiable { let dogRasse: String? } +// MARK: - Walks (Gassi-Treffen) + +struct WalkMeeting: Decodable, Identifiable { + let id: Int + let userId: Int + let titel: String + let datum: String // YYYY-MM-DD + let uhrzeit: String // HH:MM + let lat: Double + let lon: Double + let ortName: String? + let maxTeilnehmer: Int + let beschreibung: String? + let status: String? + let veranstalterName: String? + let teilnehmerCount: Int? +} + +struct WalkCreateBody: Encodable { + let titel: String + let datum: String + let uhrzeit: String + let lat: Double + let lon: Double + let ortName: String? + let maxTeilnehmer: Int + let beschreibung: String? +} + +struct WalkJoinBody: Encodable { + let dogIds: [Int] +} + struct GassiZeitCreateBody: Encodable { let dogId: Int? let wochentage: [String] diff --git a/BanYaroGo/Views/AddWalkSheet.swift b/BanYaroGo/Views/AddWalkSheet.swift new file mode 100644 index 0000000..475316f --- /dev/null +++ b/BanYaroGo/Views/AddWalkSheet.swift @@ -0,0 +1,107 @@ +import SwiftUI +import CoreLocation + +struct AddWalkSheet: View { + let coord: CLLocationCoordinate2D? + let onSaved: () async -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var titel = "" + @State private var date = Calendar.current.date(byAdding: .day, value: 1, to: .now) ?? .now + @State private var time = Calendar.current.date(bySettingHour: 17, minute: 0, second: 0, of: .now) ?? .now + @State private var ortName = "" + @State private var maxTeilnehmer = 10 + @State private var beschreibung = "" + + @State private var isSaving = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section("Titel") { + TextField("z. B. Gassi-Runde am Schlosspark", text: $titel) + } + Section("Datum & Uhrzeit") { + DatePicker("Datum", selection: $date, in: Date.now..., displayedComponents: .date) + .environment(\.locale, Locale(identifier: "de_DE")) + DatePicker("Uhrzeit", selection: $time, displayedComponents: .hourAndMinute) + .environment(\.locale, Locale(identifier: "de_DE")) + } + Section("Treffpunkt") { + TextField("Name (z. B. Hauptplatz)", text: $ortName) + if let coord { + Text(String(format: "%.5f, %.5f", coord.latitude, coord.longitude)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } else { + Text("Standort wird noch geholt — bitte einen Moment warten.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Section("Max. Teilnehmer") { + Stepper("\(maxTeilnehmer)", value: $maxTeilnehmer, in: 2...100) + } + Section("Beschreibung (optional)") { + TextField("Was ist geplant? Wie lange?", text: $beschreibung, axis: .vertical) + .lineLimit(2...6) + } + if let errorMessage { + Section { + Text(errorMessage).font(.footnote).foregroundStyle(.red) + } + } + } + .navigationTitle("Neues Treffen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + .disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { ProgressView() } + else { + Button("Sichern") { Task { await save() } } + .disabled(!canSave) + } + } + } + } + } + + private var canSave: Bool { + coord != nil && !titel.trimmingCharacters(in: .whitespaces).isEmpty + } + + private func save() async { + guard let coord else { return } + isSaving = true + errorMessage = nil + defer { isSaving = false } + + let dateFmt = DateFormatter(); dateFmt.dateFormat = "yyyy-MM-dd" + let timeFmt = DateFormatter(); timeFmt.dateFormat = "HH:mm" + + let body = WalkCreateBody( + titel: titel.trimmingCharacters(in: .whitespaces), + datum: dateFmt.string(from: date), + uhrzeit: timeFmt.string(from: time), + lat: coord.latitude, + lon: coord.longitude, + ortName: ortName.trimmingCharacters(in: .whitespaces).isEmpty ? nil : ortName, + maxTeilnehmer: maxTeilnehmer, + beschreibung: beschreibung.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : beschreibung + ) + + do { + let _: WalkMeeting = try await APIClient.shared.post("/api/walks", body: body) + await onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/BanYaroGo/Views/GassiTreffenDetail.swift b/BanYaroGo/Views/GassiTreffenDetail.swift new file mode 100644 index 0000000..733d55e --- /dev/null +++ b/BanYaroGo/Views/GassiTreffenDetail.swift @@ -0,0 +1,172 @@ +import SwiftUI +import MapKit + +struct GassiTreffenDetail: View { + let meetingId: Int + let fallbackTitle: String + let onChange: () async -> Void + + @Environment(AuthSession.self) private var auth + @Environment(\.dismiss) private var dismiss + + @State private var meeting: WalkMeeting? + @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 { + 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 { 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) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "calendar") + Text(formatDate(m.datum)) + Text(m.uhrzeit).monospacedDigit() + } + .font(.headline) + + if let ort = m.ortName, !ort.isEmpty { + Label(ort, systemImage: "mappin.and.ellipse") + .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) + } + } + .font(.subheadline) + .padding(.top, 4) + } + + if let beschreibung = m.beschreibung, !beschreibung.isEmpty { + Text(beschreibung) + .font(.body) + .padding(.top, 4) + } + + if !isOwn(m) { + joinButton(m) + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } + + 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() + } + .frame(maxWidth: .infinity, minHeight: 50) + } + .background((hasJoined == true) ? Color.red : Color.accentColor, in: Capsule()) + .foregroundStyle(.white) + .disabled(isMutating) + } + + private func isOwn(_ m: WalkMeeting) -> Bool { + m.userId == auth.profile?.id + } + + 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 + } + } + + private func toggleJoin() async { + isMutating = true + errorMessage = nil + defer { isMutating = false } + do { + if hasJoined == true { + 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() + } 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 + } +} diff --git a/BanYaroGo/Views/GassiTreffenList.swift b/BanYaroGo/Views/GassiTreffenList.swift new file mode 100644 index 0000000..eaa6345 --- /dev/null +++ b/BanYaroGo/Views/GassiTreffenList.swift @@ -0,0 +1,129 @@ +import SwiftUI + +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 + + var body: some View { + content + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAdd = true + } label: { + Label("Planen", systemImage: "plus") + } + } + } + .sheet(isPresented: $showAdd) { + AddWalkSheet(coord: location.coordinate) { await load() } + } + .task { + location.request() + await load() + } + .onChange(of: location.coordinate?.latitude) { _, _ in + Task { await load() } + } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading && meetings.isEmpty { + ProgressView() + } else if let errorMessage, meetings.isEmpty { + ContentUnavailableView( + "Konnte nicht laden", + systemImage: "wifi.slash", + description: Text(errorMessage) + ) + } 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.") + ) + } else { + List(meetings) { meeting in + NavigationLink { + GassiTreffenDetail(meetingId: meeting.id, fallbackTitle: meeting.titel) { + await load() + } + } label: { + TreffenRow(meeting: meeting) + } + } + } + } + + 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 + } +} diff --git a/BanYaroGo/Views/GassiView.swift b/BanYaroGo/Views/GassiView.swift new file mode 100644 index 0000000..76c77e5 --- /dev/null +++ b/BanYaroGo/Views/GassiView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct GassiView: View { + @State private var tab: Tab = .treffen + + private enum Tab: String, CaseIterable, Identifiable { + case treffen = "Treffen" + case stammGassis = "Stamm-Gassis" + var id: String { rawValue } + } + + var body: some View { + VStack(spacing: 0) { + Picker("Bereich", selection: $tab) { + ForEach(Tab.allCases) { t in + Text(t.rawValue).tag(t) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.top, 8) + + Divider() + .padding(.top, 8) + + switch tab { + case .treffen: + GassiTreffenList() + case .stammGassis: + StammGassisList() + } + } + .navigationTitle("Gassi-Treffen") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/BanYaroGo/Views/GassiZeitenView.swift b/BanYaroGo/Views/GassiZeitenView.swift index 7d7660c..4970f30 100644 --- a/BanYaroGo/Views/GassiZeitenView.swift +++ b/BanYaroGo/Views/GassiZeitenView.swift @@ -1,7 +1,7 @@ import SwiftUI import UserNotifications -struct GassiZeitenView: View { +struct StammGassisList: View { @State private var items: [GassiZeit] = [] @State private var isLoading = false @State private var errorMessage: String? @@ -15,8 +15,6 @@ struct GassiZeitenView: View { var body: some View { content - .navigationTitle("Stamm-Gassi-Zeiten") - .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showAdd = true } label: { Image(systemName: "plus") } diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift index 783ea20..25906e3 100644 --- a/BanYaroGo/Views/SettingsView.swift +++ b/BanYaroGo/Views/SettingsView.swift @@ -29,9 +29,9 @@ struct SettingsView: View { Section("Hund & Alltag") { NavigationLink { - GassiZeitenView() + GassiView() } label: { - Label("Stamm-Gassi-Zeiten", systemImage: "alarm.fill") + Label("Gassi-Treffen", systemImage: "pawprint.fill") } NavigationLink { GiftkoederView()