diff --git a/BanYaroGo.entitlements b/BanYaroGo.entitlements
index 03d07da..ae2de8f 100644
--- a/BanYaroGo.entitlements
+++ b/BanYaroGo.entitlements
@@ -8,5 +8,9 @@
com.apple.developer.weatherkit
+ com.apple.security.application-groups
+
+ group.app.banyaro.ios
+
diff --git a/BanYaroGo.xcodeproj/project.pbxproj b/BanYaroGo.xcodeproj/project.pbxproj
index 904124b..04e4a5c 100644
--- a/BanYaroGo.xcodeproj/project.pbxproj
+++ b/BanYaroGo.xcodeproj/project.pbxproj
@@ -343,7 +343,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = H436BR6YWX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -369,7 +369,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = H436BR6YWX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -391,8 +391,9 @@
BB0000000000000000000031 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = H436BR6YWX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
@@ -415,8 +416,9 @@
BB0000000000000000000032 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = H436BR6YWX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift
index 83a9c65..cc7b733 100644
--- a/BanYaroGo/API/DTOs.swift
+++ b/BanYaroGo/API/DTOs.swift
@@ -259,7 +259,7 @@ struct DiaryEntry: Decodable, Identifiable {
var isMilestoneFlag: Bool { isMilestone == 1 }
}
-struct DiaryMedia: Decodable, Identifiable {
+struct DiaryMedia: Codable, Identifiable {
let id: Int
let url: String
let mediaType: String?
diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift
index 1225152..72f4602 100644
--- a/BanYaroGo/BanYaroGoApp.swift
+++ b/BanYaroGo/BanYaroGoApp.swift
@@ -3,6 +3,7 @@ import SwiftData
@main
struct BanYaroGoApp: App {
+ @Environment(\.scenePhase) private var scenePhase
@State private var auth = AuthSession()
@State private var activeDog = ActiveDogStore()
@State private var pendingGPX: GPXTrack?
@@ -12,6 +13,9 @@ struct BanYaroGoApp: App {
RootView()
.environment(auth)
.environment(activeDog)
+ .onChange(of: scenePhase) { _, phase in
+ if phase == .active { WalkLauncher.shared.consumePendingFlag() }
+ }
.onOpenURL { url in
handleIncoming(url: url)
}
@@ -24,7 +28,11 @@ struct BanYaroGoApp: App {
}
}
}
- .modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
+ .modelContainer(for: [
+ ActiveWalk.self, PhotoLocation.self,
+ CachedRoute.self, CachedDiaryEntry.self, CachedImage.self,
+ PendingRoute.self, PendingRoutePhoto.self
+ ])
}
private func handleIncoming(url: URL) {
diff --git a/BanYaroGo/Support/HomeWidgetData.swift b/BanYaroGo/Support/HomeWidgetData.swift
new file mode 100644
index 0000000..541b06a
--- /dev/null
+++ b/BanYaroGo/Support/HomeWidgetData.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+/// Vom App-Target geschrieben, vom Widget-Target gelesen — über die App Group
+/// `group.app.banyaro.ios` geteilt. Bewusst in beiden Targets identisch
+/// dupliziert (statt Shared/-pbxproj-Handarbeit); JSON-kompatibel.
+struct HomeWidgetData: Codable {
+ var dogName: String
+ var photoJPEG: Data?
+ var nextAppointment: String?
+ var diaryCount: Int?
+ var updatedAt: Date
+}
+
+enum HomeWidgetStore {
+ static let appGroup = "group.app.banyaro.ios"
+ static let key = "homeWidgetData"
+
+ static func save(_ data: HomeWidgetData) {
+ guard let defaults = UserDefaults(suiteName: appGroup),
+ let encoded = try? JSONEncoder().encode(data) else { return }
+ defaults.set(encoded, forKey: key)
+ }
+
+ static func load() -> HomeWidgetData? {
+ guard let defaults = UserDefaults(suiteName: appGroup),
+ let data = defaults.data(forKey: key) else { return nil }
+ return try? JSONDecoder().decode(HomeWidgetData.self, from: data)
+ }
+
+ static func clear() {
+ UserDefaults(suiteName: appGroup)?.removeObject(forKey: key)
+ }
+}
diff --git a/BanYaroGo/Support/OfflineCache.swift b/BanYaroGo/Support/OfflineCache.swift
new file mode 100644
index 0000000..e009c5f
--- /dev/null
+++ b/BanYaroGo/Support/OfflineCache.swift
@@ -0,0 +1,293 @@
+import Foundation
+import SwiftData
+
+// MARK: - SwiftData-Cache-Modelle
+//
+// Spiegeln die API-DTOs (RouteListItem/RouteDetail/DiaryEntry), damit Touren
+// und Tagebuch offline lesbar sind. Fotos werden separat als CachedImage-Blobs
+// gehalten (voll offline). Geleert bei Logout/401 via OfflineCache.clearAll.
+
+@Model final class CachedRoute {
+ @Attribute(.unique) var id: Int
+ var userId: Int
+ var name: String
+ var beschreibung: String?
+ var distanzKm: Double?
+ var dauerMin: Int?
+ var createdAt: String?
+ var userName: String?
+ var isPublic: Bool
+ var previewTrackData: Data? // JSON [GPSPoint]
+ var gpsTrackData: Data? // JSON [GPSPoint] — erst beim Öffnen der Detailansicht gefüllt
+ var fotoPathsData: Data? // JSON [String]
+ var cachedAt: Date
+
+ init(from item: RouteListItem) {
+ id = item.id
+ userId = item.userId
+ name = item.name
+ beschreibung = item.beschreibung
+ distanzKm = item.distanzKm
+ dauerMin = item.dauerMin
+ createdAt = item.createdAt
+ userName = item.userName
+ isPublic = item.isPublic ?? false
+ previewTrackData = CacheJSON.encode(item.previewTrack)
+ gpsTrackData = nil
+ fotoPathsData = CacheJSON.encode(item.fotoUrls ?? [])
+ cachedAt = Date()
+ }
+
+ func apply(_ item: RouteListItem) {
+ userId = item.userId
+ name = item.name
+ beschreibung = item.beschreibung
+ distanzKm = item.distanzKm
+ dauerMin = item.dauerMin
+ createdAt = item.createdAt
+ userName = item.userName
+ isPublic = item.isPublic ?? false
+ previewTrackData = CacheJSON.encode(item.previewTrack)
+ fotoPathsData = CacheJSON.encode(item.fotoUrls ?? [])
+ cachedAt = Date()
+ }
+
+ func apply(detail: RouteDetail) {
+ name = detail.name
+ beschreibung = detail.beschreibung
+ distanzKm = detail.distanzKm
+ dauerMin = detail.dauerMin
+ createdAt = detail.createdAt
+ userName = detail.userName
+ gpsTrackData = CacheJSON.encode(detail.gpsTrack)
+ fotoPathsData = CacheJSON.encode(detail.fotoUrls ?? [])
+ cachedAt = Date()
+ }
+
+ var fotoPaths: [String] { CacheJSON.decode([String].self, fotoPathsData) ?? [] }
+
+ func toListItem() -> RouteListItem {
+ RouteListItem(
+ id: id, userId: userId, name: name, beschreibung: beschreibung,
+ distanzKm: distanzKm, dauerMin: dauerMin, createdAt: createdAt,
+ previewTrack: CacheJSON.decode([GPSPoint].self, previewTrackData) ?? [],
+ fotoUrls: fotoPaths, userName: userName, isPublic: isPublic
+ )
+ }
+
+ /// Liefert eine Detail-Repräsentation aus dem Cache, sofern bereits ein
+ /// voller gps_track gespeichert wurde (sonst nil → online nachladen).
+ func toDetail() -> RouteDetail? {
+ guard let track: [GPSPoint] = CacheJSON.decode([GPSPoint].self, gpsTrackData), !track.isEmpty else { return nil }
+ return RouteDetail(
+ id: id, userId: userId, name: name, beschreibung: beschreibung,
+ distanzKm: distanzKm, dauerMin: dauerMin, gpsTrack: track,
+ fotoUrls: fotoPaths, createdAt: createdAt, userName: userName, dogIds: nil
+ )
+ }
+}
+
+@Model final class CachedDiaryEntry {
+ @Attribute(.unique) var id: Int
+ var dogId: Int?
+ var datum: String?
+ var typ: String?
+ var titel: String?
+ var text: String?
+ var tagsData: Data?
+ var gpsLat: Double?
+ var gpsLon: Double?
+ var locationName: String?
+ var isMilestone: Int?
+ var mediaData: Data? // JSON [DiaryMedia]
+ var createdAt: String?
+ var cachedAt: Date
+
+ init(from e: DiaryEntry) {
+ id = e.id
+ dogId = e.dogId
+ datum = e.datum
+ typ = e.typ
+ titel = e.titel
+ text = e.text
+ tagsData = CacheJSON.encode(e.tags ?? [])
+ gpsLat = e.gpsLat
+ gpsLon = e.gpsLon
+ locationName = e.locationName
+ isMilestone = e.isMilestone
+ mediaData = CacheJSON.encode(e.mediaItems ?? [])
+ createdAt = e.createdAt
+ cachedAt = Date()
+ }
+
+ func apply(_ e: DiaryEntry) {
+ dogId = e.dogId
+ datum = e.datum
+ typ = e.typ
+ titel = e.titel
+ text = e.text
+ tagsData = CacheJSON.encode(e.tags ?? [])
+ gpsLat = e.gpsLat
+ gpsLon = e.gpsLon
+ locationName = e.locationName
+ isMilestone = e.isMilestone
+ mediaData = CacheJSON.encode(e.mediaItems ?? [])
+ createdAt = e.createdAt
+ cachedAt = Date()
+ }
+
+ var mediaPaths: [String] {
+ (CacheJSON.decode([DiaryMedia].self, mediaData) ?? []).map(\.url)
+ }
+
+ func toEntry() -> DiaryEntry {
+ DiaryEntry(
+ id: id, dogId: dogId, datum: datum, typ: typ, titel: titel, text: text,
+ tags: CacheJSON.decode([String].self, tagsData),
+ gpsLat: gpsLat, gpsLon: gpsLon, locationName: locationName,
+ isMilestone: isMilestone,
+ mediaItems: CacheJSON.decode([DiaryMedia].self, mediaData),
+ createdAt: createdAt
+ )
+ }
+}
+
+/// Lokal gespeicherte Bilddaten, keyed über den relativen Medienpfad
+/// (z. B. "/media/routes/x.jpg"). `externalStorage` legt große Blobs außerhalb
+/// der DB ab.
+@Model final class CachedImage {
+ @Attribute(.unique) var path: String
+ @Attribute(.externalStorage) var data: Data?
+ var cachedAt: Date
+
+ init(path: String, data: Data?) {
+ self.path = path
+ self.data = data
+ self.cachedAt = Date()
+ }
+}
+
+// MARK: - JSON-Helfer
+
+enum CacheJSON {
+ private static let encoder = JSONEncoder()
+ private static let decoder = JSONDecoder()
+ static func encode(_ value: T) -> Data? { try? encoder.encode(value) }
+ static func decode(_ type: T.Type, _ data: Data?) -> T? {
+ guard let data else { return nil }
+ return try? decoder.decode(type, from: data)
+ }
+}
+
+// MARK: - Cache-Operationen
+
+@MainActor
+enum OfflineCache {
+
+ static let mediaBase = "https://banyaro.app"
+
+ // — Touren —
+
+ static func upsertRoutes(_ items: [RouteListItem], in ctx: ModelContext) {
+ for item in items {
+ let rid = item.id
+ if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == rid })).first {
+ existing.apply(item)
+ } else {
+ ctx.insert(CachedRoute(from: item))
+ }
+ }
+ try? ctx.save()
+ }
+
+ static func cachedRoutes(in ctx: ModelContext) -> [RouteListItem] {
+ let desc = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
+ let cached = (try? ctx.fetch(desc)) ?? []
+ return cached.map { $0.toListItem() }
+ }
+
+ static func upsertRouteDetail(_ detail: RouteDetail, in ctx: ModelContext) {
+ let rid = detail.id
+ if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == rid })).first {
+ existing.apply(detail: detail)
+ } else {
+ // Kommt vom Detail ohne vorherigen Listeneintrag: minimal anlegen.
+ let stub = RouteListItem(
+ id: detail.id, userId: detail.userId, name: detail.name,
+ beschreibung: detail.beschreibung, distanzKm: detail.distanzKm,
+ dauerMin: detail.dauerMin, createdAt: detail.createdAt,
+ previewTrack: detail.gpsTrack, fotoUrls: detail.fotoUrls,
+ userName: detail.userName, isPublic: nil
+ )
+ let route = CachedRoute(from: stub)
+ route.apply(detail: detail)
+ ctx.insert(route)
+ }
+ try? ctx.save()
+ }
+
+ static func cachedRouteDetail(id: Int, in ctx: ModelContext) -> RouteDetail? {
+ try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == id })).first?.toDetail()
+ }
+
+ // — Tagebuch —
+
+ static func upsertDiary(_ entries: [DiaryEntry], in ctx: ModelContext) {
+ for e in entries {
+ let eid = e.id
+ if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.id == eid })).first {
+ existing.apply(e)
+ } else {
+ ctx.insert(CachedDiaryEntry(from: e))
+ }
+ }
+ try? ctx.save()
+ }
+
+ static func cachedDiary(dogId: Int, in ctx: ModelContext) -> [DiaryEntry] {
+ let desc = FetchDescriptor(
+ predicate: #Predicate { $0.dogId == dogId },
+ sortBy: [SortDescriptor(\.datum, order: .reverse)]
+ )
+ let cached = (try? ctx.fetch(desc)) ?? []
+ return cached.map { $0.toEntry() }
+ }
+
+ // — Bilder —
+
+ static func imageData(path: String, in ctx: ModelContext) -> Data? {
+ (try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.path == path })).first)?.data
+ }
+
+ static func storeImage(path: String, data: Data, in ctx: ModelContext) {
+ if let existing = try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.path == path })).first {
+ existing.data = data
+ existing.cachedAt = Date()
+ } else {
+ ctx.insert(CachedImage(path: path, data: data))
+ }
+ try? ctx.save()
+ }
+
+ /// Lädt fehlende Foto-Pfade herunter und legt sie lokal ab (für volles Offline).
+ static func prefetchImages(paths: [String], in ctx: ModelContext) async {
+ for path in paths where !path.isEmpty {
+ let exists = (try? ctx.fetch(FetchDescriptor(predicate: #Predicate { $0.path == path })).first) != nil
+ if exists { continue }
+ guard let url = URL(string: mediaBase + path) else { continue }
+ if let (data, _) = try? await URLSession.shared.data(from: url) {
+ storeImage(path: path, data: data, in: ctx)
+ }
+ }
+ }
+
+ // — Aufräumen —
+
+ /// Komplett leeren — bei Logout/401, damit kein fremder User-Cache durchschlägt.
+ static func clearAll(in ctx: ModelContext) {
+ try? ctx.delete(model: CachedRoute.self)
+ try? ctx.delete(model: CachedDiaryEntry.self)
+ try? ctx.delete(model: CachedImage.self)
+ try? ctx.save()
+ }
+}
diff --git a/BanYaroGo/Support/OneShotLocation.swift b/BanYaroGo/Support/OneShotLocation.swift
index 0aa9794..cc3c27b 100644
--- a/BanYaroGo/Support/OneShotLocation.swift
+++ b/BanYaroGo/Support/OneShotLocation.swift
@@ -3,11 +3,26 @@ import Observation
import CoreLocation
/// Asks CLLocationManager for the user's current location once. Used by
-/// Wetter and Giftköder which need a position without the full tracking setup.
+/// Wetter und Giftköder, die eine Position ohne das volle Tracking-Setup
+/// brauchen.
+///
+/// Zwei Wege:
+/// - `resolve()` (async): liefert die Koordinate **oder nil** nach Fix, Fehler,
+/// verweigerter Berechtigung oder **Timeout**. Deterministisch — keine
+/// Abhängigkeit von SwiftUI-`onChange`-Zustandswechseln.
+/// - `request()` (observable): füllt `coordinate`/`error`/`isResolving` für
+/// Views, die den Zustand direkt beobachten.
+///
+/// Der Timeout verhindert, dass ein Screen ewig bei „Hole Standort…" hängt,
+/// wenn CoreLocation (z. B. auf einem Review-iPad ohne Position) weder Fix noch
+/// Fehler liefert.
@Observable
@MainActor
final class OneShotLocation: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
+ private var timeoutTask: Task?
+ private let timeoutSeconds: Double = 10
+ private var continuation: CheckedContinuation?
var coordinate: CLLocationCoordinate2D?
var error: String?
@@ -19,20 +34,61 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
+ /// Beobachtbarer Weg (Giftköder): startet die Auflösung, Ergebnis landet in
+ /// `coordinate`/`error`.
func request() {
+ isResolving = true
+ Task { _ = await resolve() }
+ }
+
+ /// Async-Weg (Wetter): wartet auf Fix/Fehler/Timeout und liefert die
+ /// Koordinate oder nil. Aufrufer kann danach z. B. einen Fallback nutzen.
+ func resolve() async -> CLLocationCoordinate2D? {
+ if let coordinate { return coordinate }
+ // Schon eine Auflösung im Gange? Nicht doppelt starten (Continuation-Leak).
+ if continuation != nil { return nil }
+
error = nil
isResolving = true
- switch manager.authorizationStatus {
- case .notDetermined:
- manager.requestWhenInUseAuthorization()
- case .denied, .restricted:
- error = "Standortzugriff verweigert."
- isResolving = false
- case .authorizedWhenInUse, .authorizedAlways:
- manager.requestLocation()
- @unknown default:
- error = "Unbekannter Standort-Status."
- isResolving = false
+ return await withCheckedContinuation { (cont: CheckedContinuation) in
+ self.continuation = cont
+ self.startTimeout()
+ switch self.manager.authorizationStatus {
+ case .notDetermined:
+ self.manager.requestWhenInUseAuthorization()
+ case .denied, .restricted:
+ self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
+ case .authorizedWhenInUse, .authorizedAlways:
+ self.manager.requestLocation()
+ @unknown default:
+ self.complete(coord: nil, errorText: "Unbekannter Standort-Status.")
+ }
+ }
+ }
+
+ private func startTimeout() {
+ timeoutTask?.cancel()
+ let seconds = timeoutSeconds
+ timeoutTask = Task { [weak self] in
+ try? await Task.sleep(for: .seconds(seconds))
+ guard let self, !Task.isCancelled else { return }
+ if self.continuation != nil {
+ self.complete(coord: nil, errorText: "Standort nicht ermittelbar (Zeitüberschreitung).")
+ }
+ }
+ }
+
+ /// Einziger Endpunkt für jeden Ausgang. Resümiert die Continuation genau
+ /// einmal und aktualisiert den beobachtbaren Zustand.
+ private func complete(coord: CLLocationCoordinate2D?, errorText: String?) {
+ timeoutTask?.cancel()
+ timeoutTask = nil
+ if let coord { coordinate = coord }
+ if coord == nil, let errorText { error = errorText }
+ isResolving = false
+ if let cont = continuation {
+ continuation = nil
+ cont.resume(returning: coord ?? coordinate)
}
}
@@ -40,31 +96,24 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
- guard let loc = locations.first else { return }
- let c = loc.coordinate
- Task { @MainActor in
- self.coordinate = c
- self.isResolving = false
- }
+ guard let c = locations.first?.coordinate else { return }
+ Task { @MainActor in self.complete(coord: c, errorText: nil) }
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) {
let msg = err.localizedDescription
- Task { @MainActor in
- self.error = msg
- self.isResolving = false
- }
+ Task { @MainActor in self.complete(coord: nil, errorText: msg) }
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
+ guard self.continuation != nil else { return }
switch status {
case .authorizedWhenInUse, .authorizedAlways:
manager.requestLocation()
case .denied, .restricted:
- self.error = "Standortzugriff verweigert."
- self.isResolving = false
+ self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
default:
break
}
diff --git a/BanYaroGo/Support/Outbox.swift b/BanYaroGo/Support/Outbox.swift
new file mode 100644
index 0000000..0f7bd77
--- /dev/null
+++ b/BanYaroGo/Support/Outbox.swift
@@ -0,0 +1,112 @@
+import Foundation
+import SwiftData
+
+// MARK: - Outbox: offline gesicherte Touren
+//
+// Wird eine Tour ohne Internet gespeichert, landet sie als PendingRoute (inkl.
+// unterwegs hinzugefügter Fotos als Blobs) lokal in SwiftData und wird per
+// OfflineCache.syncPendingRoutes hochgeladen, sobald wieder Netz da ist.
+
+@Model final class PendingRoute {
+ @Attribute(.unique) var localId: UUID
+ var name: String
+ var gpsTrackData: Data? // JSON [GPSPoint]
+ var distanzKm: Double
+ var dauerMin: Int
+ var dogIdsData: Data? // JSON [Int]
+ var isPublic: Bool
+ var createdAt: Date
+ @Relationship(deleteRule: .cascade, inverse: \PendingRoutePhoto.route)
+ var photos: [PendingRoutePhoto]
+
+ init(body: RouteCreateBody) {
+ localId = UUID()
+ name = body.name
+ gpsTrackData = CacheJSON.encode(body.gpsTrack)
+ distanzKm = body.distanzKm
+ dauerMin = body.dauerMin
+ dogIdsData = CacheJSON.encode(body.dogIds)
+ isPublic = body.isPublic
+ createdAt = Date()
+ photos = []
+ }
+
+ var gpsTrack: [GPSPoint] { CacheJSON.decode([GPSPoint].self, gpsTrackData) ?? [] }
+ var dogIds: [Int] { CacheJSON.decode([Int].self, dogIdsData) ?? [] }
+
+ func toCreateBody() -> RouteCreateBody {
+ RouteCreateBody(
+ name: name, gpsTrack: gpsTrack, distanzKm: distanzKm,
+ dauerMin: dauerMin, dogIds: dogIds, isPublic: isPublic
+ )
+ }
+}
+
+@Model final class PendingRoutePhoto {
+ var localId: UUID
+ @Attribute(.externalStorage) var data: Data
+ var lat: Double?
+ var lon: Double?
+ var order: Int
+ var route: PendingRoute?
+
+ init(data: Data, lat: Double?, lon: Double?, order: Int) {
+ localId = UUID()
+ self.data = data
+ self.lat = lat
+ self.lon = lon
+ self.order = order
+ }
+}
+
+// MARK: - Outbox-Operationen
+
+extension OfflineCache {
+
+ /// Sichert eine Tour (mit unterwegs hinzugefügten Fotos) offline lokal.
+ static func savePendingRoute(body: RouteCreateBody, photos: [CapturedPhoto], in ctx: ModelContext) {
+ let pending = PendingRoute(body: body)
+ ctx.insert(pending)
+ for (i, p) in photos.enumerated() {
+ let photo = PendingRoutePhoto(data: p.data, lat: p.location?.lat, lon: p.location?.lon, order: i)
+ photo.route = pending
+ ctx.insert(photo)
+ }
+ try? ctx.save()
+ }
+
+ static func pendingRoutesCount(in ctx: ModelContext) -> Int {
+ (try? ctx.fetchCount(FetchDescriptor())) ?? 0
+ }
+
+ /// Lädt alle offline gesicherten Touren (inkl. Fotos) hoch. Erfolgreiche
+ /// werden lokal gelöscht. Schlägt der Upload fehl (weiter offline), bleibt
+ /// die Tour liegen und wird beim nächsten Trigger erneut versucht.
+ @discardableResult
+ static func syncPendingRoutes(in ctx: ModelContext) async -> Int {
+ let pending = (try? ctx.fetch(
+ FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .forward)])
+ )) ?? []
+ var uploaded = 0
+ for route in pending {
+ do {
+ let created: RouteDetail = try await APIClient.shared.post("/api/routes", body: route.toCreateBody())
+ for photo in route.photos.sorted(by: { $0.order < $1.order }) {
+ let resized = ImageResize.resizedJPEG(from: photo.data)
+ _ = try? await APIClient.shared.uploadFile(
+ "/api/routes/\(created.id)/photo",
+ filename: "photo_\(photo.order + 1).jpg",
+ data: resized
+ )
+ }
+ ctx.delete(route) // cascade löscht die Fotos mit
+ try? ctx.save()
+ uploaded += 1
+ } catch {
+ // Weiter offline oder Serverfehler → liegen lassen, später erneut.
+ break
+ }
+ }
+ return uploaded
+ }
+}
diff --git a/BanYaroGo/Support/StartWalkIntent.swift b/BanYaroGo/Support/StartWalkIntent.swift
new file mode 100644
index 0000000..b16a75d
--- /dev/null
+++ b/BanYaroGo/Support/StartWalkIntent.swift
@@ -0,0 +1,32 @@
+import AppIntents
+
+/// Siri-/Kurzbefehl-Intent „Gassi gehen": öffnet die App und stößt die
+/// Aufzeichnung an (über das App-Group-Flag, das die App beim Aktivwerden liest).
+struct StartWalkIntent: AppIntent {
+ static var title: LocalizedStringResource = "Gassi gehen"
+ static var description = IntentDescription("Startet die Aufzeichnung einer Gassi-Tour in Ban Yaro Go.")
+ static var openAppWhenRun: Bool = true
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ WalkLauncher.requestStartViaAppGroup()
+ WalkLauncher.shared.pendingStart = true // Fast-Path, falls in-process
+ return .result()
+ }
+}
+
+/// Macht den Intent als Siri-Phrase + Kurzbefehl verfügbar (automatisch erkannt).
+struct BanYaroAppShortcuts: AppShortcutsProvider {
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: StartWalkIntent(),
+ phrases: [
+ "Gassi gehen mit \(.applicationName)",
+ "Geh Gassi mit \(.applicationName)",
+ "\(.applicationName) Gassi gehen"
+ ],
+ shortTitle: "Gassi gehen",
+ systemImageName: "figure.walk"
+ )
+ }
+}
diff --git a/BanYaroGo/Support/WalkLauncher.swift b/BanYaroGo/Support/WalkLauncher.swift
new file mode 100644
index 0000000..e80152c
--- /dev/null
+++ b/BanYaroGo/Support/WalkLauncher.swift
@@ -0,0 +1,33 @@
+import Foundation
+import Observation
+
+/// Brücke vom Siri-Kurzbefehl „Gassi gehen" zur UI.
+///
+/// Der App Intent läuft evtl. außerhalb des App-Prozesses → er setzt ein Flag in
+/// der **App Group**. Beim Aktivwerden liest die App das Flag und stößt über
+/// `pendingStart` den Wechsel auf den Aufnehmen-Tab + den Aufnahme-Start an.
+@Observable
+@MainActor
+final class WalkLauncher {
+ static let shared = WalkLauncher()
+ private init() {}
+
+ /// UI-Signal: true → Aufnehmen-Tab wählen und Aufnahme starten.
+ var pendingStart = false
+
+ static let appGroup = "group.app.banyaro.ios"
+ static let flagKey = "pendingStartWalk"
+
+ /// Vom App Intent aufgerufen (cross-process über die App Group).
+ static func requestStartViaAppGroup() {
+ UserDefaults(suiteName: appGroup)?.set(true, forKey: flagKey)
+ }
+
+ /// Beim Aktivwerden der App das Flag einlösen → `pendingStart`.
+ func consumePendingFlag() {
+ let defaults = UserDefaults(suiteName: Self.appGroup)
+ guard defaults?.bool(forKey: Self.flagKey) == true else { return }
+ defaults?.removeObject(forKey: Self.flagKey)
+ pendingStart = true
+ }
+}
diff --git a/BanYaroGo/Views/CachedAsyncImage.swift b/BanYaroGo/Views/CachedAsyncImage.swift
new file mode 100644
index 0000000..0b2450e
--- /dev/null
+++ b/BanYaroGo/Views/CachedAsyncImage.swift
@@ -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: 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)
+ }
+ }
+}
diff --git a/BanYaroGo/Views/DiaryDetailView.swift b/BanYaroGo/Views/DiaryDetailView.swift
index 35b2714..9083392 100644
--- a/BanYaroGo/Views/DiaryDetailView.swift
+++ b/BanYaroGo/Views/DiaryDetailView.swift
@@ -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)
diff --git a/BanYaroGo/Views/DogsListView.swift b/BanYaroGo/Views/DogsListView.swift
deleted file mode 100644
index 087a440..0000000
--- a/BanYaroGo/Views/DogsListView.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift
index 2f70660..3d6f7f7 100644
--- a/BanYaroGo/Views/FinishWalkSheet.swift
+++ b/BanYaroGo/Views/FinishWalkSheet.swift
@@ -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
+ )
+ }
}
diff --git a/BanYaroGo/Views/HeimView.swift b/BanYaroGo/Views/HeimView.swift
index b1f553c..2c08cda 100644
--- a/BanYaroGo/Views/HeimView.swift
+++ b/BanYaroGo/Views/HeimView.swift
@@ -1,4 +1,6 @@
import SwiftUI
+import WidgetKit
+import UIKit
struct HeimView: View {
@Environment(AuthSession.self) private var auth
@@ -260,5 +262,42 @@ struct HeimView: View {
}
cachedPhotoUrl = fresh
}
+
+ await updateWidgetSnapshot(dog: dog)
+ }
+
+ /// Schreibt einen Snapshot (kleines Foto + nächster Termin) in die App Group
+ /// fürs Home-Screen-Widget und triggert ein Widget-Reload.
+ private func updateWidgetSnapshot(dog: Dog) async {
+ var photoJPEG: Data?
+ if let path = dashboard?.randomPhoto?.previewUrl ?? dashboard?.randomPhoto?.url,
+ let url = URL(string: "https://banyaro.app\(path)"),
+ let (data, _) = try? await URLSession.shared.data(from: url) {
+ photoJPEG = Self.widgetThumbnail(from: data)
+ }
+ let appt: String? = {
+ guard let a = dashboard?.nextAppointment, let bez = a.bezeichnung, !bez.isEmpty else { return nil }
+ if let date = a.naechstes { return "\(bez) · \(DiaryUtil.format(date))" }
+ return bez
+ }()
+ HomeWidgetStore.save(HomeWidgetData(
+ dogName: dog.name,
+ photoJPEG: photoJPEG,
+ nextAppointment: appt,
+ diaryCount: dashboard?.diaryCount,
+ updatedAt: Date()
+ ))
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+
+ private static func widgetThumbnail(from data: Data, max: CGFloat = 600) -> Data? {
+ guard let img = UIImage(data: data) else { return nil }
+ let longest = Swift.max(img.size.width, img.size.height)
+ let scale = longest > max ? max / longest : 1
+ let size = CGSize(width: img.size.width * scale, height: img.size.height * scale)
+ let rendered = UIGraphicsImageRenderer(size: size).image { _ in
+ img.draw(in: CGRect(origin: .zero, size: size))
+ }
+ return rendered.jpegData(compressionQuality: 0.7)
}
}
diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift
index 02e52e5..d5ccc5b 100644
--- a/BanYaroGo/Views/MainTabView.swift
+++ b/BanYaroGo/Views/MainTabView.swift
@@ -1,22 +1,38 @@
import SwiftUI
+import SwiftData
struct MainTabView: View {
@Environment(AuthSession.self) private var auth
+ @Environment(\.modelContext) private var ctx
+ @State private var selectedTab = 0
+ private let launcher = WalkLauncher.shared
var body: some View {
- TabView {
+ TabView(selection: $selectedTab) {
HeimView()
.tabItem { Label("Heim", systemImage: "house.fill") }
+ .tag(0)
RoutesListView()
.tabItem { Label("Touren", systemImage: "map.fill") }
+ .tag(1)
TrackingView()
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
+ .tag(2)
SettingsView()
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
+ .tag(3)
+ }
+ .task {
+ await auth.loadProfile()
+ // Offline gesicherte Touren beim Start hochladen (falls online).
+ await OfflineCache.syncPendingRoutes(in: ctx)
+ launcher.consumePendingFlag()
+ }
+ .onChange(of: launcher.pendingStart) { _, pending in
+ if pending { selectedTab = 2 }
}
- .task { await auth.loadProfile() }
}
}
diff --git a/BanYaroGo/Views/RootView.swift b/BanYaroGo/Views/RootView.swift
index fced642..f8d814b 100644
--- a/BanYaroGo/Views/RootView.swift
+++ b/BanYaroGo/Views/RootView.swift
@@ -1,13 +1,33 @@
import SwiftUI
+import SwiftData
+import WidgetKit
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()
+ }
+ }
+ // Bei User-Wechsel (Logout oder 401) alle nutzerbezogenen lokalen Daten
+ // leeren — Offline-Cache UND Widget-Snapshot —, damit nie Touren,
+ // Tagebuch, Fotos oder der Hund eines vorigen Users durchschimmern.
+ .onReceive(NotificationCenter.default.publisher(for: .userDidLogout)) { _ in
+ clearOnUserChange()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .apiUnauthorized)) { _ in
+ clearOnUserChange()
}
}
+
+ private func clearOnUserChange() {
+ OfflineCache.clearAll(in: ctx)
+ HomeWidgetStore.clear()
+ WidgetCenter.shared.reloadAllTimelines()
+ }
}
diff --git a/BanYaroGo/Views/RouteDetailView.swift b/BanYaroGo/Views/RouteDetailView.swift
index 7d7a1f9..67170b0 100644
--- a/BanYaroGo/Views/RouteDetailView.swift
+++ b/BanYaroGo/Views/RouteDetailView.swift
@@ -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)
}
}
}
diff --git a/BanYaroGo/Views/RoutesListView.swift b/BanYaroGo/Views/RoutesListView.swift
index dedf87c..3f4734e 100644
--- a/BanYaroGo/Views/RoutesListView.swift
+++ b/BanYaroGo/Views/RoutesListView.swift
@@ -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.
diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift
index 5438e79..13fdfe7 100644
--- a/BanYaroGo/Views/SettingsView.swift
+++ b/BanYaroGo/Views/SettingsView.swift
@@ -98,28 +98,27 @@ struct SettingsView: View {
Text("Auto-Pause: pausiert die Aufnahme, wenn du 2 Minuten lang stehen bleibst.\nApple Health: schreibt jede gespeicherte Tour als Spaziergang-Workout mit Route in Health.")
}
- 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")
- }
-
Section {
if let url = URL(string: "https://banyaro.app") {
Link(destination: url) {
- Label("banyaro.app in Safari öffnen", systemImage: "safari.fill")
- .foregroundStyle(.primary)
+ HStack {
+ Label("banyaro.app öffnen", systemImage: "safari.fill")
+ .foregroundStyle(.primary)
+ Spacer()
+ Image(systemName: "arrow.up.right.square")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
}
}
} header: {
- Text("banyaro.app als Web-App")
+ Text("banyaro.app")
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Du kannst banyaro.app zusätzlich als Web-App auf deinem Home-Bildschirm ablegen — praktisch für alle Features, die diese App nicht abbildet.")
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("**1.**")
- Text("Oben „in Safari öffnen“ tippen")
+ Text("Oben „banyaro.app öffnen“ tippen")
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("**2.**")
@@ -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)
}
@@ -261,20 +260,4 @@ struct SettingsView: View {
if path.hasPrefix("http") { return URL(string: path) }
return URL(string: "https://banyaro.app\(path)")
}
-
- @ViewBuilder
- private func pwaLink(_ title: String, systemImage: String, fragment: String) -> some View {
- if let url = URL(string: "https://banyaro.app/#\(fragment)") {
- Link(destination: url) {
- HStack {
- Label(title, systemImage: systemImage)
- .foregroundStyle(.primary)
- Spacer()
- Image(systemName: "arrow.up.right.square")
- .font(.caption)
- .foregroundStyle(.tertiary)
- }
- }
- }
- }
}
diff --git a/BanYaroGo/Views/TagebuchView.swift b/BanYaroGo/Views/TagebuchView.swift
index ab8138e..62f89f5 100644
--- a/BanYaroGo/Views/TagebuchView.swift
+++ b/BanYaroGo/Views/TagebuchView.swift
@@ -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))
diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift
index 13d79ce..aefe066 100644
--- a/BanYaroGo/Views/TrackingView.swift
+++ b/BanYaroGo/Views/TrackingView.swift
@@ -14,6 +14,7 @@ struct TrackingView: View {
@State private var showCamera = false
@State private var showResumeDialog = false
@State private var didCheckResume = false
+ private let launcher = WalkLauncher.shared
private let clockTicker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let persistTicker = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
@@ -45,7 +46,13 @@ struct TrackingView: View {
}
.onChange(of: tracker.isPaused) { _, _ in updateLiveActivity() }
.onChange(of: tracker.isAutoPaused) { _, _ in updateLiveActivity() }
- .onAppear { offerResumeIfNeeded() }
+ .onAppear {
+ offerResumeIfNeeded()
+ startIfRequested()
+ }
+ .onChange(of: launcher.pendingStart) { _, pending in
+ if pending { startIfRequested() }
+ }
.sheet(isPresented: $showFinishSheet) {
FinishWalkSheet(
points: tracker.points,
@@ -269,6 +276,14 @@ struct TrackingView: View {
tracker.startOrRequest()
}
+ /// Vom Siri-Kurzbefehl „Gassi gehen" angestoßen: Aufnahme starten, sofern
+ /// nicht schon eine läuft.
+ private func startIfRequested() {
+ guard launcher.pendingStart, !tracker.isTracking else { return }
+ launcher.pendingStart = false
+ startFresh()
+ }
+
private func offerResumeIfNeeded() {
guard !didCheckResume else { return }
didCheckResume = true
diff --git a/BanYaroGo/Views/WetterView.swift b/BanYaroGo/Views/WetterView.swift
index e04aec8..d8b618a 100644
--- a/BanYaroGo/Views/WetterView.swift
+++ b/BanYaroGo/Views/WetterView.swift
@@ -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
diff --git a/BanYaroGoWidget.entitlements b/BanYaroGoWidget.entitlements
new file mode 100644
index 0000000..93071dd
--- /dev/null
+++ b/BanYaroGoWidget.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.app.banyaro.ios
+
+
+
diff --git a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
index 318e7f0..1126b63 100644
--- a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
+++ b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
@@ -5,5 +5,6 @@ import SwiftUI
struct BanYaroGoWidgetBundle: WidgetBundle {
var body: some Widget {
WalkLiveActivity()
+ BanYaroHomeWidget()
}
}
diff --git a/BanYaroGoWidget/BanYaroHomeWidget.swift b/BanYaroGoWidget/BanYaroHomeWidget.swift
new file mode 100644
index 0000000..8960237
--- /dev/null
+++ b/BanYaroGoWidget/BanYaroHomeWidget.swift
@@ -0,0 +1,88 @@
+import WidgetKit
+import SwiftUI
+import UIKit
+
+struct BanYaroEntry: TimelineEntry {
+ let date: Date
+ let data: HomeWidgetData?
+}
+
+struct BanYaroProvider: TimelineProvider {
+ func placeholder(in context: Context) -> BanYaroEntry {
+ BanYaroEntry(date: Date(), data: nil)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (BanYaroEntry) -> Void) {
+ completion(BanYaroEntry(date: Date(), data: HomeWidgetStore.load()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let entry = BanYaroEntry(date: Date(), data: HomeWidgetStore.load())
+ // Die App pusht bei Updates reloadAllTimelines(); als Sicherheitsnetz
+ // stündlich neu laden.
+ let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
+ ?? Date().addingTimeInterval(3600)
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+}
+
+struct BanYaroHomeWidgetEntryView: View {
+ @Environment(\.widgetFamily) private var family
+ let entry: BanYaroEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Spacer()
+ Text(entry.data?.dogName ?? "Ban Yaro")
+ .font(family == .systemSmall ? .headline : .title3)
+ .bold()
+ .foregroundStyle(.white)
+ .shadow(radius: 3)
+ if family != .systemSmall {
+ if let appt = entry.data?.nextAppointment, !appt.isEmpty {
+ Label(appt, systemImage: "calendar")
+ .font(.caption)
+ .foregroundStyle(.white)
+ .shadow(radius: 3)
+ } else if let n = entry.data?.diaryCount, n > 0 {
+ Label("\(n) Tagebuch-Einträge", systemImage: "book")
+ .font(.caption)
+ .foregroundStyle(.white.opacity(0.9))
+ .shadow(radius: 3)
+ } else {
+ Text("Schön, dass du da bist 🐾")
+ .font(.caption)
+ .foregroundStyle(.white.opacity(0.9))
+ .shadow(radius: 3)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct BanYaroHomeWidget: Widget {
+ let kind = "BanYaroHomeWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: BanYaroProvider()) { entry in
+ BanYaroHomeWidgetEntryView(entry: entry)
+ .containerBackground(for: .widget) {
+ if let jpeg = entry.data?.photoJPEG, let ui = UIImage(data: jpeg) {
+ ZStack {
+ Image(uiImage: ui).resizable().scaledToFill()
+ LinearGradient(
+ colors: [.black.opacity(0.0), .black.opacity(0.55)],
+ startPoint: .center, endPoint: .bottom
+ )
+ }
+ } else {
+ Color.accentColor.opacity(0.25)
+ }
+ }
+ }
+ .configurationDisplayName("Ban Yaro")
+ .description("Tagesfoto deines Hundes und nächster Termin.")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+}
diff --git a/BanYaroGoWidget/HomeWidgetData.swift b/BanYaroGoWidget/HomeWidgetData.swift
new file mode 100644
index 0000000..9d32037
--- /dev/null
+++ b/BanYaroGoWidget/HomeWidgetData.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// Kopie im Widget-Target (identisch zur App-Variante). Über die App Group
+/// `group.app.banyaro.ios` liest das Widget den von der App geschriebenen Stand.
+struct HomeWidgetData: Codable {
+ var dogName: String
+ var photoJPEG: Data?
+ var nextAppointment: String?
+ var diaryCount: Int?
+ var updatedAt: Date
+}
+
+enum HomeWidgetStore {
+ static let appGroup = "group.app.banyaro.ios"
+ static let key = "homeWidgetData"
+
+ static func load() -> HomeWidgetData? {
+ guard let defaults = UserDefaults(suiteName: appGroup),
+ let data = defaults.data(forKey: key) else { return nil }
+ return try? JSONDecoder().decode(HomeWidgetData.self, from: data)
+ }
+}