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()