1.1: Offline-Cache + Outbox für Touren/Tagebuch, WeatherKit-Fix, Aufräumen
App-Review-Fix (Guideline 2.1 WeatherKit): - OneShotLocation: deterministisches async resolve() mit 10s-Timeout statt onChange-Lauschen; WetterView lädt bei fehlendem Standort einen Berlin-Fallback → kein ewiges Hängen bei "Hole Standort…", WeatherKit ist immer sichtbar. Offline-Lesen (SwiftData): - CachedRoute/CachedDiaryEntry/CachedImage + CachedAsyncImage: Touren, Tagebuch und Fotos werden cache-first geladen und sind offline verfügbar. - Cache wird bei Logout/401 geleert (RootView), kein Durchschimmern fremder User. Offline-Speichern (Outbox): - PendingRoute/PendingRoutePhoto: Tour inkl. unterwegs hinzugefügter Fotos wird offline lokal gesichert und automatisch hochgeladen (Touren-Tab + App-Start). - Touren-Liste zeigt offline gesicherte Touren mit "wird hochgeladen"-Badge. FinishWalkSheet: - Dismiss-Schutz: Speichern-Dialog lässt sich nicht mehr wegwischen — eine aufgezeichnete Tour geht nicht mehr durch Runterwischen verloren. Wetter: - Ortslabel (Reverse-Geocoding; Fallback "Berlin · Näherung"). - Saubere Offline-Meldung statt rohem networkError. Aufräumen: - Doppeltes "Gassi-Treffen" im Mehr-Tab entfernt. - Veraltete Phase-1/2-Texte neu getextet. - Tote DogsListView gelöscht (Hund-Wechsel läuft über den Heim-Picker).
This commit is contained in:
parent
9e51f3910e
commit
a2646a18ef
16 changed files with 769 additions and 199 deletions
51
BanYaroGo/Views/CachedAsyncImage.swift
Normal file
51
BanYaroGo/Views/CachedAsyncImage.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
import SwiftData
|
||||
|
||||
/// Wie `AsyncImage`, aber offline-fähig: zeigt zuerst ein lokal gecachtes Bild
|
||||
/// (CachedImage in SwiftData); fehlt es, wird es remote geladen und dabei gleich
|
||||
/// in den Offline-Cache geschrieben. `path` ist der relative Medienpfad
|
||||
/// (z. B. "/media/routes/x.jpg"), die Basis-URL hängt OfflineCache an.
|
||||
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
private let path: String?
|
||||
private let content: (Image) -> Content
|
||||
private let placeholder: () -> Placeholder
|
||||
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@State private var uiImage: UIImage?
|
||||
|
||||
init(
|
||||
path: String?,
|
||||
@ViewBuilder content: @escaping (Image) -> Content,
|
||||
@ViewBuilder placeholder: @escaping () -> Placeholder
|
||||
) {
|
||||
self.path = path
|
||||
self.content = content
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let uiImage {
|
||||
content(Image(uiImage: uiImage))
|
||||
} else {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
.task(id: path) { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
uiImage = nil
|
||||
guard let path, !path.isEmpty else { return }
|
||||
if let data = OfflineCache.imageData(path: path, in: ctx), let img = UIImage(data: data) {
|
||||
uiImage = img
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: OfflineCache.mediaBase + path) else { return }
|
||||
if let (data, _) = try? await URLSession.shared.data(from: url), let img = UIImage(data: data) {
|
||||
uiImage = img
|
||||
OfflineCache.storeImage(path: path, data: data, in: ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,14 +91,10 @@ struct DiaryDetailView: View {
|
|||
VStack(spacing: 8) {
|
||||
TabView(selection: $photoIndex) {
|
||||
ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in
|
||||
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in
|
||||
switch phase {
|
||||
case .success(let img): img.resizable().scaledToFit()
|
||||
case .failure: Image(systemName: "photo")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
default: ProgressView()
|
||||
}
|
||||
CachedAsyncImage(path: m.url) { img in
|
||||
img.resizable().scaledToFit()
|
||||
} placeholder: {
|
||||
ProgressView()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.tag(idx)
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
struct DogsListView: View {
|
||||
@State private var dogs: [Dog] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle("Hunde")
|
||||
.task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading && dogs.isEmpty {
|
||||
ProgressView()
|
||||
} else if let error = errorMessage, dogs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Konnte Hunde nicht laden",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text(error)
|
||||
)
|
||||
} else if dogs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Hunde",
|
||||
systemImage: "pawprint",
|
||||
description: Text("Lege deinen ersten Hund in der PWA an.")
|
||||
)
|
||||
} else {
|
||||
List(dogs) { dog in
|
||||
DogRow(dog: dog)
|
||||
}
|
||||
.refreshable { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
dogs = try await APIClient.shared.get("/api/dogs")
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DogRow: View {
|
||||
let dog: Dog
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
avatar
|
||||
.frame(width: 56, height: 56)
|
||||
.background(.background.secondary)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(dog.name).font(.headline)
|
||||
if let rasse = dog.rasse, !rasse.isEmpty {
|
||||
Text(rasse)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatar: some View {
|
||||
if let path = dog.fotoUrl, let url = URL(string: "https://banyaro.app\(path)") {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().scaledToFill()
|
||||
default:
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
Image(systemName: "pawprint.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ struct FinishWalkSheet: View {
|
|||
|
||||
@State private var saveState: SaveState = .idle
|
||||
@State private var errorMessage: String?
|
||||
@State private var savedOffline = false
|
||||
|
||||
private enum SaveState: Equatable {
|
||||
case idle
|
||||
|
|
@ -144,7 +145,15 @@ struct FinishWalkSheet: View {
|
|||
.onChange(of: photoSelection) { _, newItems in
|
||||
Task { await loadPhotos(from: newItems) }
|
||||
}
|
||||
.interactiveDismissDisabled(saveState != .idle)
|
||||
// Immer blockieren: eine aufgezeichnete Tour darf nicht durch
|
||||
// versehentliches Runterwischen verloren gehen — nur „Speichern"
|
||||
// oder „Verwerfen" beenden das Sheet.
|
||||
.interactiveDismissDisabled(true)
|
||||
.alert("Offline gespeichert", isPresented: $savedOffline) {
|
||||
Button("OK") { onSaved(); dismiss() }
|
||||
} message: {
|
||||
Text("Keine Internetverbindung. Die Tour ist lokal gesichert und wird automatisch hochgeladen, sobald du wieder online bist.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,6 +292,15 @@ struct FinishWalkSheet: View {
|
|||
do {
|
||||
route = try await APIClient.shared.post("/api/routes", body: body)
|
||||
} catch {
|
||||
// Transportfehler (offline) → Tour inkl. Fotos lokal in die Outbox,
|
||||
// wird automatisch hochgeladen, sobald wieder Netz da ist.
|
||||
if error is URLError {
|
||||
OfflineCache.savePendingRoute(body: body, photos: photoData, in: modelContext)
|
||||
await syncHealthIfEnabled()
|
||||
saveState = .idle
|
||||
savedOffline = true
|
||||
return
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
saveState = .idle
|
||||
return
|
||||
|
|
@ -321,19 +339,21 @@ struct FinishWalkSheet: View {
|
|||
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
|
||||
}
|
||||
|
||||
// Apple Health sync (only if user opted in)
|
||||
if healthKitSyncEnabled, points.count >= 2 {
|
||||
let endedAt = Date.now
|
||||
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
|
||||
await WalkHealthSync.shared.saveWalk(
|
||||
points: points,
|
||||
startedAt: startedAt,
|
||||
endedAt: endedAt,
|
||||
distanceMeters: distanceMeters
|
||||
)
|
||||
}
|
||||
await syncHealthIfEnabled()
|
||||
|
||||
onSaved()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func syncHealthIfEnabled() async {
|
||||
guard healthKitSyncEnabled, points.count >= 2 else { return }
|
||||
let endedAt = Date.now
|
||||
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
|
||||
await WalkHealthSync.shared.saveWalk(
|
||||
points: points,
|
||||
startedAt: startedAt,
|
||||
endedAt: endedAt,
|
||||
distanceMeters: distanceMeters
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(AuthSession.self) private var auth
|
||||
@Environment(\.modelContext) private var ctx
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
|
|
@ -17,6 +19,10 @@ struct MainTabView: View {
|
|||
SettingsView()
|
||||
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
|
||||
}
|
||||
.task { await auth.loadProfile() }
|
||||
.task {
|
||||
await auth.loadProfile()
|
||||
// Offline gesicherte Touren beim Start hochladen (falls online).
|
||||
await OfflineCache.syncPendingRoutes(in: ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(AuthSession.self) private var auth
|
||||
@Environment(\.modelContext) private var ctx
|
||||
|
||||
var body: some View {
|
||||
if auth.isLoggedIn {
|
||||
MainTabView()
|
||||
} else {
|
||||
LoginView()
|
||||
Group {
|
||||
if auth.isLoggedIn {
|
||||
MainTabView()
|
||||
} else {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
// Offline-Cache leeren, sobald der User wechselt (Logout oder 401),
|
||||
// damit nie Touren/Tagebuch/Fotos eines vorigen Users durchschimmern.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .userDidLogout)) { _ in
|
||||
OfflineCache.clearAll(in: ctx)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .apiUnauthorized)) { _ in
|
||||
OfflineCache.clearAll(in: ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ struct RouteDetailView: View {
|
|||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AuthSession.self) private var auth
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Query private var allPhotoLocations: [PhotoLocation]
|
||||
|
||||
@State private var detail: RouteDetail?
|
||||
|
|
@ -218,27 +219,32 @@ struct RouteDetailView: View {
|
|||
}
|
||||
|
||||
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))
|
||||
}
|
||||
CachedAsyncImage(path: path) { img in
|
||||
img.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
Rectangle().fill(.gray.opacity(0.15))
|
||||
}
|
||||
.frame(width: 160, height: 160)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
// 1) Cache-first: gespeicherten Detail-Stand zeigen (auch offline)
|
||||
if detail == nil, let cached = OfflineCache.cachedRouteDetail(id: routeId, in: ctx) {
|
||||
detail = cached
|
||||
}
|
||||
isLoading = (detail == nil)
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
// 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
|
||||
do {
|
||||
detail = try await APIClient.shared.get("/api/routes/\(routeId)")
|
||||
let fetched: RouteDetail = try await APIClient.shared.get("/api/routes/\(routeId)")
|
||||
detail = fetched
|
||||
OfflineCache.upsertRouteDetail(fetched, in: ctx)
|
||||
let paths = fetched.fotoUrls ?? []
|
||||
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if detail == nil { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,17 +313,11 @@ private struct PhotoViewerSheet: View {
|
|||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
if let url = URL(string: "https://banyaro.app\(path)") {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().scaledToFit()
|
||||
case .failure:
|
||||
ContentUnavailableView("Foto nicht ladbar", systemImage: "photo.badge.exclamationmark")
|
||||
.foregroundStyle(.white)
|
||||
default:
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
if !path.isEmpty {
|
||||
CachedAsyncImage(path: path) { img in
|
||||
img.resizable().scaledToFit()
|
||||
} placeholder: {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct RoutesListView: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Query(sort: \PendingRoute.createdAt, order: .reverse) private var pending: [PendingRoute]
|
||||
@State private var routes: [RouteListItem] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
|
@ -15,26 +18,39 @@ struct RoutesListView: View {
|
|||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading && routes.isEmpty {
|
||||
if isLoading && routes.isEmpty && pending.isEmpty {
|
||||
ProgressView()
|
||||
} else if let error = errorMessage, routes.isEmpty {
|
||||
} else if let error = errorMessage, routes.isEmpty && pending.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Konnte Touren nicht laden",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text(error)
|
||||
)
|
||||
} else if routes.isEmpty {
|
||||
} else if routes.isEmpty && pending.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Touren",
|
||||
systemImage: "map",
|
||||
description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.")
|
||||
description: Text("Zeichne deine erste Gassi-Tour über den Tab „Aufnehmen“ auf — oder importiere einen GPX-Track.")
|
||||
)
|
||||
} else {
|
||||
List(routes) { route in
|
||||
NavigationLink {
|
||||
RouteDetailView(routeId: route.id, fallbackName: route.name)
|
||||
} label: {
|
||||
RouteRowView(route: route)
|
||||
List {
|
||||
if !pending.isEmpty {
|
||||
Section {
|
||||
ForEach(pending) { p in
|
||||
PendingRouteRow(route: p)
|
||||
}
|
||||
} header: {
|
||||
Label("Offline – wird hochgeladen", systemImage: "icloud.and.arrow.up")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(routes) { route in
|
||||
NavigationLink {
|
||||
RouteDetailView(routeId: route.id, fallbackName: route.name)
|
||||
} label: {
|
||||
RouteRowView(route: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await load() }
|
||||
|
|
@ -42,13 +58,25 @@ struct RoutesListView: View {
|
|||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
// 1) Cache-first: sofort anzeigen (auch offline)
|
||||
if routes.isEmpty {
|
||||
let cached = OfflineCache.cachedRoutes(in: ctx)
|
||||
if !cached.isEmpty { routes = cached }
|
||||
}
|
||||
isLoading = routes.isEmpty
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
// 2) Offline gesicherte Touren hochladen (falls online)
|
||||
await OfflineCache.syncPendingRoutes(in: ctx)
|
||||
// 3) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
|
||||
do {
|
||||
routes = try await APIClient.shared.get("/api/routes")
|
||||
let fetched: [RouteListItem] = try await APIClient.shared.get("/api/routes")
|
||||
routes = fetched
|
||||
OfflineCache.upsertRoutes(fetched, in: ctx)
|
||||
let paths = fetched.flatMap { $0.fotoUrls ?? [] }
|
||||
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if routes.isEmpty { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +118,42 @@ struct RouteRowView: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct PendingRouteRow: View {
|
||||
let route: PendingRoute
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(route.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "%.1f km", route.distanzKm))
|
||||
Text("\(route.dauerMin) min")
|
||||
if !route.photos.isEmpty {
|
||||
Label("\(route.photos.count)", systemImage: "photo")
|
||||
}
|
||||
Spacer()
|
||||
Text("noch nicht hochgeladen")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
let track = route.gpsTrack
|
||||
if track.count >= 2 {
|
||||
MiniRouteMap(track: track)
|
||||
.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.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,6 @@ struct SettingsView: View {
|
|||
Section("Mehr auf banyaro.app") {
|
||||
pwaLink("Forum", systemImage: "bubble.left.and.bubble.right.fill", fragment: "forum")
|
||||
pwaLink("Hunde-Profile bearbeiten", systemImage: "pawprint.fill", fragment: "dogs")
|
||||
pwaLink("Gassi-Treffen", systemImage: "person.2.fill", fragment: "walks")
|
||||
pwaLink("Profil & Einstellungen", systemImage: "gearshape.fill", fragment: "settings")
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +169,7 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
Section("Über") {
|
||||
Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.")
|
||||
Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app — fürs Gassi-Tracking, Wetter, Tagebuch und mehr unterwegs.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagebuchView: View {
|
||||
@Environment(ActiveDogStore.self) private var activeDog
|
||||
@Environment(\.modelContext) private var ctx
|
||||
|
||||
@State private var entries: [DiaryEntry] = []
|
||||
@State private var isLoading = false
|
||||
|
|
@ -66,16 +68,26 @@ struct TagebuchView: View {
|
|||
|
||||
private func load() async {
|
||||
guard let dog = activeDog.activeDog else { return }
|
||||
isLoading = true
|
||||
// 1) Cache-first: sofort anzeigen (auch offline)
|
||||
if entries.isEmpty {
|
||||
let cached = OfflineCache.cachedDiary(dogId: dog.id, in: ctx)
|
||||
if !cached.isEmpty { entries = cached }
|
||||
}
|
||||
isLoading = entries.isEmpty
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
// 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
|
||||
do {
|
||||
entries = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50")
|
||||
let fetched: [DiaryEntry] = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50")
|
||||
entries = fetched
|
||||
OfflineCache.upsertDiary(fetched, in: ctx)
|
||||
let paths = fetched.flatMap { ($0.mediaItems ?? []).map(\.url) }
|
||||
Task { await OfflineCache.prefetchImages(paths: paths, in: ctx) }
|
||||
} catch let decodingError as DecodingError {
|
||||
errorMessage = Self.describe(decodingError)
|
||||
if entries.isEmpty { errorMessage = Self.describe(decodingError) }
|
||||
print("Tagebuch decode error: \(decodingError)")
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if entries.isEmpty { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,11 +143,10 @@ private struct DiaryRow: View {
|
|||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(media) { m in
|
||||
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in
|
||||
switch phase {
|
||||
case .success(let img): img.resizable().scaledToFill()
|
||||
default: Rectangle().fill(.gray.opacity(0.15))
|
||||
}
|
||||
CachedAsyncImage(path: m.url) { img in
|
||||
img.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
Rectangle().fill(.gray.opacity(0.15))
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ struct WetterView: View {
|
|||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var selectedDayIndex = 0
|
||||
@State private var placeName: String?
|
||||
@State private var isApproxLocation = false
|
||||
|
||||
/// Fallback-Standort (Berlin-Mitte): Wenn der Gerätestandort nicht
|
||||
/// ermittelbar ist (Timeout/verweigert — z. B. Apple-Review-iPad ohne
|
||||
/// Position), lädt WeatherKit trotzdem eine Vorhersage, statt ewig bei
|
||||
/// „Hole Standort…" zu hängen.
|
||||
private static let fallbackCoordinate = CLLocationCoordinate2D(
|
||||
latitude: 52.5200, longitude: 13.4050
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
@ -33,11 +43,23 @@ struct WetterView: View {
|
|||
}
|
||||
.navigationTitle("Gassi-Wetter")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { location.request() }
|
||||
.onChange(of: location.coordinate?.latitude) { _, _ in
|
||||
Task { await loadWeather() }
|
||||
.task {
|
||||
// Deterministisch: erst Standort auflösen (Fix/Fehler/Timeout),
|
||||
// dann laden — mit echtem Standort oder Berlin-Fallback. So hängt
|
||||
// der Screen nie bei „Hole Standort…" und WeatherKit lädt immer.
|
||||
let coord = await location.resolve()
|
||||
isApproxLocation = (coord == nil)
|
||||
let used = coord ?? Self.fallbackCoordinate
|
||||
await loadWeather(coord: used)
|
||||
placeName = isApproxLocation ? "Berlin" : await Self.reverseGeocode(used)
|
||||
}
|
||||
.refreshable {
|
||||
let coord = location.coordinate
|
||||
isApproxLocation = (coord == nil)
|
||||
let used = coord ?? Self.fallbackCoordinate
|
||||
await loadWeather(coord: used)
|
||||
placeName = isApproxLocation ? "Berlin" : await Self.reverseGeocode(used)
|
||||
}
|
||||
.refreshable { await loadWeather() }
|
||||
}
|
||||
|
||||
private func content(weather: Weather) -> some View {
|
||||
|
|
@ -46,6 +68,18 @@ struct WetterView: View {
|
|||
|
||||
return ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
if let placeName {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: isApproxLocation ? "location.slash" : "location.fill")
|
||||
Text(placeName)
|
||||
if isApproxLocation {
|
||||
Text("· Näherung").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if !days.isEmpty {
|
||||
dayPicker(days: days)
|
||||
let day = days[safeIndex]
|
||||
|
|
@ -421,8 +455,7 @@ struct WetterView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func loadWeather() async {
|
||||
guard let coord = location.coordinate else { return }
|
||||
private func loadWeather(coord: CLLocationCoordinate2D) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
|
@ -430,9 +463,25 @@ struct WetterView: View {
|
|||
do {
|
||||
weather = try await WeatherService.shared.weather(for: loc)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorMessage = Self.isOfflineError(error)
|
||||
? "Wetter ist offline nicht verfügbar. Die Vorhersage lädt automatisch, sobald du wieder Internet hast."
|
||||
: error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse-Geocoding → Ortsname (Stadt). Braucht Netz; offline → nil.
|
||||
private static func reverseGeocode(_ coord: CLLocationCoordinate2D) async -> String? {
|
||||
let loc = CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||
let placemarks = try? await CLGeocoder().reverseGeocodeLocation(loc)
|
||||
return placemarks?.first?.locality ?? placemarks?.first?.name
|
||||
}
|
||||
|
||||
private static func isOfflineError(_ error: Error) -> Bool {
|
||||
if error is URLError { return true }
|
||||
let d = error.localizedDescription.lowercased()
|
||||
return d.contains("internet") || d.contains("verbindung")
|
||||
|| d.contains("network") || d.contains("offline")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day metrics derived from DayWeather
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue