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.
205 lines
8 KiB
Swift
205 lines
8 KiB
Swift
import SwiftUI
|
|
import UserNotifications
|
|
|
|
struct StammGassisList: View {
|
|
@State private var items: [GassiZeit] = []
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var showAdd = false
|
|
|
|
private let weekdayLabels: [String: String] = [
|
|
"mo": "Mo", "di": "Di", "mi": "Mi", "do": "Do",
|
|
"fr": "Fr", "sa": "Sa", "so": "So"
|
|
]
|
|
private let weekdayOrder = ["mo", "di", "mi", "do", "fr", "sa", "so"]
|
|
|
|
var body: some View {
|
|
content
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button { showAdd = true } label: { Image(systemName: "plus") }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showAdd) {
|
|
AddGassiZeitSheet { await load() }
|
|
}
|
|
.task {
|
|
_ = try? await UNUserNotificationCenter.current()
|
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
|
await load()
|
|
}
|
|
.refreshable { await load() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if isLoading && items.isEmpty {
|
|
ProgressView()
|
|
} else if let errorMessage, items.isEmpty {
|
|
ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage))
|
|
} else if items.isEmpty {
|
|
ContentUnavailableView(
|
|
"Noch keine Stamm-Gassi-Zeiten",
|
|
systemImage: "alarm",
|
|
description: Text("Trag deine regelmäßigen Gassi-Runden ein — du bekommst lokale Erinnerungen, und in der banyaro.app sehen andere, wann ihr euch verabreden könnt.")
|
|
)
|
|
} else {
|
|
List {
|
|
Section {
|
|
ForEach(items) { z in
|
|
row(z)
|
|
}
|
|
} footer: {
|
|
Text("Deine Zeiten landen auch im Stamm-Gassi-Pool der Community (sichtbar in banyaro.app → Gassi). Erinnerungen kommen lokal vom iPhone — auch ohne Internet.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func row(_ z: GassiZeit) -> some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "alarm.fill")
|
|
.foregroundStyle(z.aktiv == 0 ? Color.secondary : Color.accentColor)
|
|
.frame(width: 28)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
Text(z.uhrzeit).font(.headline.monospacedDigit())
|
|
Spacer()
|
|
if let dog = z.dogName {
|
|
Text(dog).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
HStack(spacing: 6) {
|
|
ForEach(weekdayOrder, id: \.self) { wd in
|
|
let active = z.wochentage.contains(wd)
|
|
Text(weekdayLabels[wd] ?? wd)
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(active ? .white : .secondary)
|
|
.frame(width: 22, height: 22)
|
|
.background(active ? Color.accentColor : Color.secondary.opacity(0.15), in: Circle())
|
|
}
|
|
}
|
|
if let n = z.notiz, !n.isEmpty {
|
|
Text(n).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func load() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
do {
|
|
items = try await APIClient.shared.get("/api/gassi-zeiten?nur_eigene=true")
|
|
// Re-sync local notifications to reflect server state.
|
|
for z in items where z.isMine ?? true {
|
|
await GassiZeitenScheduler.reschedule(z)
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AddGassiZeitSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let onSaved: () async -> Void
|
|
|
|
@State private var time = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
|
@State private var selectedDays: Set<String> = ["mo", "di", "mi", "do", "fr"]
|
|
@State private var notiz = ""
|
|
@State private var dogs: [Dog] = []
|
|
@State private var dogId: Int?
|
|
@State private var isSaving = false
|
|
@State private var errorMessage: String?
|
|
|
|
private let weekdayOrder = ["mo", "di", "mi", "do", "fr", "sa", "so"]
|
|
private let weekdayLabels: [String: String] = [
|
|
"mo": "Mo", "di": "Di", "mi": "Mi", "do": "Do",
|
|
"fr": "Fr", "sa": "Sa", "so": "So"
|
|
]
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Uhrzeit") {
|
|
DatePicker("Uhrzeit", selection: $time, displayedComponents: .hourAndMinute)
|
|
.environment(\.locale, Locale(identifier: "de_DE"))
|
|
}
|
|
Section("Wochentage") {
|
|
HStack(spacing: 8) {
|
|
ForEach(weekdayOrder, id: \.self) { wd in
|
|
let active = selectedDays.contains(wd)
|
|
Button {
|
|
if active { selectedDays.remove(wd) } else { selectedDays.insert(wd) }
|
|
} label: {
|
|
Text(weekdayLabels[wd] ?? wd)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(active ? .white : .primary)
|
|
.frame(width: 34, height: 34)
|
|
.background(active ? Color.accentColor : Color.secondary.opacity(0.15), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
Section("Hund (optional)") {
|
|
Picker("Hund", selection: $dogId) {
|
|
Text("Ohne Hund").tag(Int?.none)
|
|
ForEach(dogs) { d in Text(d.name).tag(Int?.some(d.id)) }
|
|
}
|
|
}
|
|
Section("Notiz (optional)") {
|
|
TextField("Notiz", text: $notiz, axis: .vertical)
|
|
.lineLimit(2...4)
|
|
}
|
|
if let errorMessage {
|
|
Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) }
|
|
}
|
|
}
|
|
.navigationTitle("Neue Gassi-Zeit")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Abbrechen") { dismiss() }.disabled(isSaving)
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
if isSaving { ProgressView() }
|
|
else { Button("Sichern") { Task { await save() } }.disabled(selectedDays.isEmpty) }
|
|
}
|
|
}
|
|
.task { dogs = (try? await APIClient.shared.get("/api/dogs")) ?? [] }
|
|
}
|
|
}
|
|
|
|
private func save() async {
|
|
isSaving = true
|
|
errorMessage = nil
|
|
defer { isSaving = false }
|
|
|
|
let f = DateFormatter()
|
|
f.dateFormat = "HH:mm"
|
|
let body = GassiZeitCreateBody(
|
|
dogId: dogId,
|
|
wochentage: weekdayOrder.filter { selectedDays.contains($0) },
|
|
uhrzeit: f.string(from: time),
|
|
ortName: nil,
|
|
lat: nil,
|
|
lon: nil,
|
|
radiusM: 500,
|
|
notiz: notiz.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notiz
|
|
)
|
|
do {
|
|
let created: GassiZeit = try await APIClient.shared.post("/api/gassi-zeiten", body: body)
|
|
await GassiZeitenScheduler.reschedule(created)
|
|
await onSaved()
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|