Gassi: Tabs Treffen + Stamm-Gassis wie in der PWA
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.
This commit is contained in:
parent
5c4754caff
commit
b49883ca79
9 changed files with 684 additions and 5 deletions
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2650"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000004"
|
||||||
|
BuildableName = "BanYaroGo.app"
|
||||||
|
BlueprintName = "BanYaroGo"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000004"
|
||||||
|
BuildableName = "BanYaroGo.app"
|
||||||
|
BlueprintName = "BanYaroGo"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000004"
|
||||||
|
BuildableName = "BanYaroGo.app"
|
||||||
|
BlueprintName = "BanYaroGo"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2650"
|
||||||
|
wasCreatedForAppExtension = "YES"
|
||||||
|
version = "2.0">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000020"
|
||||||
|
BuildableName = "BanYaroGoWidgetExtension.appex"
|
||||||
|
BlueprintName = "BanYaroGoWidgetExtension"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000004"
|
||||||
|
BuildableName = "BanYaroGo.app"
|
||||||
|
BlueprintName = "BanYaroGo"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = ""
|
||||||
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
|
launchStyle = "0"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
launchAutomaticallySubstyle = "2"
|
||||||
|
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||||
|
<RemoteRunnable
|
||||||
|
runnableDebuggingMode = "2"
|
||||||
|
BundleIdentifier = "com.apple.springboard">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB0000000000000000000020"
|
||||||
|
BuildableName = "BanYaroGoWidgetExtension.appex"
|
||||||
|
BlueprintName = "BanYaroGoWidgetExtension"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</RemoteRunnable>
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000004"
|
||||||
|
BuildableName = "BanYaroGo.app"
|
||||||
|
BlueprintName = "BanYaroGo"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetKind"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetDefaultView"
|
||||||
|
value = "timeline"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetFamily"
|
||||||
|
value = "systemMedium"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000004"
|
||||||
|
BuildableName = "BanYaroGo.app"
|
||||||
|
BlueprintName = "BanYaroGo"
|
||||||
|
ReferencedContainer = "container:BanYaroGo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
|
|
@ -135,6 +135,39 @@ struct GassiZeit: Decodable, Identifiable {
|
||||||
let dogRasse: String?
|
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 {
|
struct GassiZeitCreateBody: Encodable {
|
||||||
let dogId: Int?
|
let dogId: Int?
|
||||||
let wochentage: [String]
|
let wochentage: [String]
|
||||||
|
|
|
||||||
107
BanYaroGo/Views/AddWalkSheet.swift
Normal file
107
BanYaroGo/Views/AddWalkSheet.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
BanYaroGo/Views/GassiTreffenDetail.swift
Normal file
172
BanYaroGo/Views/GassiTreffenDetail.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
129
BanYaroGo/Views/GassiTreffenList.swift
Normal file
129
BanYaroGo/Views/GassiTreffenList.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
36
BanYaroGo/Views/GassiView.swift
Normal file
36
BanYaroGo/Views/GassiView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
struct GassiZeitenView: View {
|
struct StammGassisList: View {
|
||||||
@State private var items: [GassiZeit] = []
|
@State private var items: [GassiZeit] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
@ -15,8 +15,6 @@ struct GassiZeitenView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.navigationTitle("Stamm-Gassi-Zeiten")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button { showAdd = true } label: { Image(systemName: "plus") }
|
Button { showAdd = true } label: { Image(systemName: "plus") }
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ struct SettingsView: View {
|
||||||
|
|
||||||
Section("Hund & Alltag") {
|
Section("Hund & Alltag") {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GassiZeitenView()
|
GassiView()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Stamm-Gassi-Zeiten", systemImage: "alarm.fill")
|
Label("Gassi-Treffen", systemImage: "pawprint.fill")
|
||||||
}
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GiftkoederView()
|
GiftkoederView()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue