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
121
BanYaroGo/Views/RouteDetailView.swift
Normal file
121
BanYaroGo/Views/RouteDetailView.swift
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import SwiftUI
|
||||
|
||||
struct RouteDetailView: View {
|
||||
let routeId: Int
|
||||
let fallbackName: String
|
||||
|
||||
@State private var detail: RouteDetail?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let detail {
|
||||
MiniRouteMap(track: detail.gpsTrack, lineWidth: 4)
|
||||
.frame(height: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler")
|
||||
StatTile(value: formatMin(detail.dauerMin), label: "Dauer", icon: "clock")
|
||||
StatTile(value: "\(detail.gpsTrack.count)", label: "Punkte", icon: "point.3.connected.trianglepath.dotted")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if let beschreibung = detail.beschreibung, !beschreibung.isEmpty {
|
||||
Text(beschreibung)
|
||||
.font(.body)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if let urls = detail.fotoUrls, !urls.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Fotos").font(.headline)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(urls, id: \.self) { path in
|
||||
photoThumb(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Spacer(minLength: 24)
|
||||
} else if isLoading {
|
||||
ProgressView().padding(.top, 80)
|
||||
} else if let error = errorMessage {
|
||||
ContentUnavailableView(
|
||||
"Fehler",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(error)
|
||||
)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(detail?.name ?? fallbackName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private func photoThumb(_ path: String) -> some View {
|
||||
let url = URL(string: "https://banyaro.app\(path)")
|
||||
return AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().scaledToFill()
|
||||
default:
|
||||
Rectangle().fill(.gray.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(width: 160, height: 160)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
detail = try await APIClient.shared.get("/api/routes/\(routeId)")
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func formatKm(_ km: Double?) -> String {
|
||||
guard let km else { return "—" }
|
||||
return String(format: "%.2f km", km)
|
||||
}
|
||||
|
||||
private func formatMin(_ mins: Int?) -> String {
|
||||
guard let mins else { return "—" }
|
||||
if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" }
|
||||
return "\(mins) min"
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatTile: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(value)
|
||||
.font(.headline)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue