Ban Yaro Go — Phase 1 Foundation
SwiftUI/SwiftData iOS-Client, redet mit https://banyaro.app FastAPI-Backend. Bundle-ID app.banyaro.ios, Xcode-26-Projekt mit synchronisierten Ordnern. Drin: - APIClient (URLSession + Bearer + convertFromSnakeCase Decoder) - KeychainStore + AuthSession (@Observable) für persistenten Login - LoginView, MainTabView, SettingsView (mit Logout) - RoutesListView + RouteDetailView mit MapKit-Polyline aus preview_track - DogsListView mit Foto-Avatar - App-Icon (Pfote auf Banyaro-Amber)
This commit is contained in:
commit
81681130e6
20 changed files with 1129 additions and 0 deletions
111
BanYaroGo/Views/RoutesListView.swift
Normal file
111
BanYaroGo/Views/RoutesListView.swift
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import SwiftUI
|
||||
|
||||
struct RoutesListView: View {
|
||||
@State private var routes: [RouteListItem] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle("Touren")
|
||||
.task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading && routes.isEmpty {
|
||||
ProgressView()
|
||||
} else if let error = errorMessage, routes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Konnte Touren nicht laden",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text(error)
|
||||
)
|
||||
} else if routes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Touren",
|
||||
systemImage: "map",
|
||||
description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.")
|
||||
)
|
||||
} else {
|
||||
List(routes) { route in
|
||||
NavigationLink {
|
||||
RouteDetailView(routeId: route.id, fallbackName: route.name)
|
||||
} label: {
|
||||
RouteRowView(route: route)
|
||||
}
|
||||
}
|
||||
.refreshable { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
routes = try await APIClient.shared.get("/api/routes")
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RouteRowView: View {
|
||||
let route: RouteListItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(route.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if let km = route.distanzKm {
|
||||
Text(String(format: "%.1f km", km))
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
if let mins = route.dauerMin {
|
||||
Label("\(mins) min", systemImage: "clock")
|
||||
}
|
||||
if let date = route.createdAt {
|
||||
Text(DateUtil.format(date))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if route.previewTrack.count >= 2 {
|
||||
MiniRouteMap(track: route.previewTrack)
|
||||
.frame(height: 110)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
enum DateUtil {
|
||||
/// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601)
|
||||
/// into a German short date.
|
||||
static func format(_ input: String) -> String {
|
||||
let parser = DateFormatter()
|
||||
parser.locale = Locale(identifier: "en_US_POSIX")
|
||||
parser.timeZone = TimeZone(identifier: "UTC")
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ssZ"] {
|
||||
parser.dateFormat = format
|
||||
if let date = parser.date(from: input) {
|
||||
let out = DateFormatter()
|
||||
out.locale = Locale(identifier: "de_DE")
|
||||
out.dateStyle = .medium
|
||||
return out.string(from: date)
|
||||
}
|
||||
}
|
||||
return String(input.prefix(10))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue