diff --git a/BanYaroGo.entitlements b/BanYaroGo.entitlements
index ae2de8f..03d07da 100644
--- a/BanYaroGo.entitlements
+++ b/BanYaroGo.entitlements
@@ -8,9 +8,5 @@
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 04e4a5c..904124b 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 = 3;
+ CURRENT_PROJECT_VERSION = 1;
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 = 3;
+ CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -391,9 +391,8 @@
BB0000000000000000000031 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 3;
+ CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
@@ -416,9 +415,8 @@
BB0000000000000000000032 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 3;
+ CURRENT_PROJECT_VERSION = 1;
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 cc7b733..83a9c65 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: Codable, Identifiable {
+struct DiaryMedia: Decodable, Identifiable {
let id: Int
let url: String
let mediaType: String?
diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift
index 72f4602..1225152 100644
--- a/BanYaroGo/BanYaroGoApp.swift
+++ b/BanYaroGo/BanYaroGoApp.swift
@@ -3,7 +3,6 @@ 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?
@@ -13,9 +12,6 @@ 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)
}
@@ -28,11 +24,7 @@ struct BanYaroGoApp: App {
}
}
}
- .modelContainer(for: [
- ActiveWalk.self, PhotoLocation.self,
- CachedRoute.self, CachedDiaryEntry.self, CachedImage.self,
- PendingRoute.self, PendingRoutePhoto.self
- ])
+ .modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
}
private func handleIncoming(url: URL) {
diff --git a/BanYaroGo/Support/HomeWidgetData.swift b/BanYaroGo/Support/HomeWidgetData.swift
deleted file mode 100644
index 541b06a..0000000
--- a/BanYaroGo/Support/HomeWidgetData.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index e009c5f..0000000
--- a/BanYaroGo/Support/OfflineCache.swift
+++ /dev/null
@@ -1,293 +0,0 @@
-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 cc3c27b..0aa9794 100644
--- a/BanYaroGo/Support/OneShotLocation.swift
+++ b/BanYaroGo/Support/OneShotLocation.swift
@@ -3,26 +3,11 @@ import Observation
import CoreLocation
/// Asks CLLocationManager for the user's current location once. Used by
-/// 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.
+/// Wetter and Giftköder which need a position without the full tracking setup.
@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?
@@ -34,61 +19,20 @@ 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
- 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)
+ 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
}
}
@@ -96,24 +40,31 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
- guard let c = locations.first?.coordinate else { return }
- Task { @MainActor in self.complete(coord: c, errorText: nil) }
+ guard let loc = locations.first else { return }
+ let c = loc.coordinate
+ Task { @MainActor in
+ self.coordinate = c
+ self.isResolving = false
+ }
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) {
let msg = err.localizedDescription
- Task { @MainActor in self.complete(coord: nil, errorText: msg) }
+ Task { @MainActor in
+ self.error = msg
+ self.isResolving = false
+ }
}
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.complete(coord: nil, errorText: "Standortzugriff verweigert.")
+ self.error = "Standortzugriff verweigert."
+ self.isResolving = false
default:
break
}
diff --git a/BanYaroGo/Support/Outbox.swift b/BanYaroGo/Support/Outbox.swift
deleted file mode 100644
index 0f7bd77..0000000
--- a/BanYaroGo/Support/Outbox.swift
+++ /dev/null
@@ -1,112 +0,0 @@
-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
deleted file mode 100644
index b16a75d..0000000
--- a/BanYaroGo/Support/StartWalkIntent.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-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
deleted file mode 100644
index e80152c..0000000
--- a/BanYaroGo/Support/WalkLauncher.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index 0b2450e..0000000
--- a/BanYaroGo/Views/CachedAsyncImage.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-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 9083392..35b2714 100644
--- a/BanYaroGo/Views/DiaryDetailView.swift
+++ b/BanYaroGo/Views/DiaryDetailView.swift
@@ -91,10 +91,14 @@ struct DiaryDetailView: View {
VStack(spacing: 8) {
TabView(selection: $photoIndex) {
ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in
- CachedAsyncImage(path: m.url) { img in
- img.resizable().scaledToFit()
- } placeholder: {
- ProgressView()
+ 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()
+ }
}
.frame(maxWidth: .infinity)
.tag(idx)
diff --git a/BanYaroGo/Views/DogsListView.swift b/BanYaroGo/Views/DogsListView.swift
new file mode 100644
index 0000000..087a440
--- /dev/null
+++ b/BanYaroGo/Views/DogsListView.swift
@@ -0,0 +1,96 @@
+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 3d6f7f7..2f70660 100644
--- a/BanYaroGo/Views/FinishWalkSheet.swift
+++ b/BanYaroGo/Views/FinishWalkSheet.swift
@@ -25,7 +25,6 @@ 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
@@ -145,15 +144,7 @@ struct FinishWalkSheet: View {
.onChange(of: photoSelection) { _, newItems in
Task { await loadPhotos(from: newItems) }
}
- // 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.")
- }
+ .interactiveDismissDisabled(saveState != .idle)
}
}
@@ -292,15 +283,6 @@ 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
@@ -339,21 +321,19 @@ struct FinishWalkSheet: View {
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
}
- await syncHealthIfEnabled()
+ // 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
+ )
+ }
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 2c08cda..b1f553c 100644
--- a/BanYaroGo/Views/HeimView.swift
+++ b/BanYaroGo/Views/HeimView.swift
@@ -1,6 +1,4 @@
import SwiftUI
-import WidgetKit
-import UIKit
struct HeimView: View {
@Environment(AuthSession.self) private var auth
@@ -262,42 +260,5 @@ 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 d5ccc5b..02e52e5 100644
--- a/BanYaroGo/Views/MainTabView.swift
+++ b/BanYaroGo/Views/MainTabView.swift
@@ -1,38 +1,22 @@
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(selection: $selectedTab) {
+ TabView {
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 f8d814b..fced642 100644
--- a/BanYaroGo/Views/RootView.swift
+++ b/BanYaroGo/Views/RootView.swift
@@ -1,33 +1,13 @@
import SwiftUI
-import SwiftData
-import WidgetKit
struct RootView: View {
@Environment(AuthSession.self) private var auth
- @Environment(\.modelContext) private var ctx
var body: some View {
- Group {
- if auth.isLoggedIn {
- MainTabView()
- } else {
- LoginView()
- }
+ 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 67170b0..7d7a1f9 100644
--- a/BanYaroGo/Views/RouteDetailView.swift
+++ b/BanYaroGo/Views/RouteDetailView.swift
@@ -8,7 +8,6 @@ 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?
@@ -219,32 +218,27 @@ struct RouteDetailView: View {
}
private func photoThumb(_ path: String) -> some View {
- CachedAsyncImage(path: path) { img in
- img.resizable().scaledToFill()
- } placeholder: {
- Rectangle().fill(.gray.opacity(0.15))
+ 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 {
- // 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)
+ isLoading = true
errorMessage = nil
defer { isLoading = false }
- // 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
do {
- 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) }
+ detail = try await APIClient.shared.get("/api/routes/\(routeId)")
} catch {
- if detail == nil { errorMessage = error.localizedDescription }
+ errorMessage = error.localizedDescription
}
}
@@ -313,11 +307,17 @@ private struct PhotoViewerSheet: View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
- if !path.isEmpty {
- CachedAsyncImage(path: path) { img in
- img.resizable().scaledToFit()
- } placeholder: {
- ProgressView().tint(.white)
+ 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)
+ }
}
}
}
diff --git a/BanYaroGo/Views/RoutesListView.swift b/BanYaroGo/Views/RoutesListView.swift
index 3f4734e..dedf87c 100644
--- a/BanYaroGo/Views/RoutesListView.swift
+++ b/BanYaroGo/Views/RoutesListView.swift
@@ -1,9 +1,6 @@
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?
@@ -18,39 +15,26 @@ struct RoutesListView: View {
@ViewBuilder
private var content: some View {
- if isLoading && routes.isEmpty && pending.isEmpty {
+ if isLoading && routes.isEmpty {
ProgressView()
- } else if let error = errorMessage, routes.isEmpty && pending.isEmpty {
+ } else if let error = errorMessage, routes.isEmpty {
ContentUnavailableView(
"Konnte Touren nicht laden",
systemImage: "wifi.slash",
description: Text(error)
)
- } else if routes.isEmpty && pending.isEmpty {
+ } else if routes.isEmpty {
ContentUnavailableView(
"Keine Touren",
systemImage: "map",
- description: Text("Zeichne deine erste Gassi-Tour über den Tab „Aufnehmen“ auf — oder importiere einen GPX-Track.")
+ description: Text("Lege deine erste Gassi-Tour in der PWA an — oder warte auf Phase 2.")
)
} else {
- 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)
- }
- }
+ List(routes) { route in
+ NavigationLink {
+ RouteDetailView(routeId: route.id, fallbackName: route.name)
+ } label: {
+ RouteRowView(route: route)
}
}
.refreshable { await load() }
@@ -58,25 +42,13 @@ struct RoutesListView: View {
}
private func load() async {
- // 1) Cache-first: sofort anzeigen (auch offline)
- if routes.isEmpty {
- let cached = OfflineCache.cachedRoutes(in: ctx)
- if !cached.isEmpty { routes = cached }
- }
- isLoading = routes.isEmpty
+ isLoading = true
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 {
- 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) }
+ routes = try await APIClient.shared.get("/api/routes")
} catch {
- if routes.isEmpty { errorMessage = error.localizedDescription }
+ errorMessage = error.localizedDescription
}
}
}
@@ -118,42 +90,6 @@ 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 13fdfe7..5438e79 100644
--- a/BanYaroGo/Views/SettingsView.swift
+++ b/BanYaroGo/Views/SettingsView.swift
@@ -98,27 +98,28 @@ 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) {
- HStack {
- Label("banyaro.app öffnen", systemImage: "safari.fill")
- .foregroundStyle(.primary)
- Spacer()
- Image(systemName: "arrow.up.right.square")
- .font(.caption)
- .foregroundStyle(.tertiary)
- }
+ Label("banyaro.app in Safari öffnen", systemImage: "safari.fill")
+ .foregroundStyle(.primary)
}
}
} header: {
- Text("banyaro.app")
+ Text("banyaro.app als Web-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 „banyaro.app öffnen“ tippen")
+ Text("Oben „in Safari öffnen“ tippen")
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("**2.**")
@@ -169,7 +170,7 @@ struct SettingsView: View {
}
Section("Über") {
- Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app — fürs Gassi-Tracking, Wetter, Tagebuch und mehr unterwegs.")
+ Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.")
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -260,4 +261,20 @@ 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 62f89f5..ab8138e 100644
--- a/BanYaroGo/Views/TagebuchView.swift
+++ b/BanYaroGo/Views/TagebuchView.swift
@@ -1,9 +1,7 @@
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
@@ -68,26 +66,16 @@ struct TagebuchView: View {
private func load() async {
guard let dog = activeDog.activeDog else { return }
- // 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
+ isLoading = true
errorMessage = nil
defer { isLoading = false }
- // 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
do {
- 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) }
+ entries = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50")
} catch let decodingError as DecodingError {
- if entries.isEmpty { errorMessage = Self.describe(decodingError) }
+ errorMessage = Self.describe(decodingError)
print("Tagebuch decode error: \(decodingError)")
} catch {
- if entries.isEmpty { errorMessage = error.localizedDescription }
+ errorMessage = error.localizedDescription
}
}
@@ -143,10 +131,11 @@ private struct DiaryRow: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(media) { m in
- CachedAsyncImage(path: m.url) { img in
- img.resizable().scaledToFill()
- } placeholder: {
- Rectangle().fill(.gray.opacity(0.15))
+ 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))
+ }
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift
index aefe066..13d79ce 100644
--- a/BanYaroGo/Views/TrackingView.swift
+++ b/BanYaroGo/Views/TrackingView.swift
@@ -14,7 +14,6 @@ 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()
@@ -46,13 +45,7 @@ struct TrackingView: View {
}
.onChange(of: tracker.isPaused) { _, _ in updateLiveActivity() }
.onChange(of: tracker.isAutoPaused) { _, _ in updateLiveActivity() }
- .onAppear {
- offerResumeIfNeeded()
- startIfRequested()
- }
- .onChange(of: launcher.pendingStart) { _, pending in
- if pending { startIfRequested() }
- }
+ .onAppear { offerResumeIfNeeded() }
.sheet(isPresented: $showFinishSheet) {
FinishWalkSheet(
points: tracker.points,
@@ -276,14 +269,6 @@ 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 d8b618a..e04aec8 100644
--- a/BanYaroGo/Views/WetterView.swift
+++ b/BanYaroGo/Views/WetterView.swift
@@ -8,16 +8,6 @@ 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 {
@@ -43,23 +33,11 @@ struct WetterView: View {
}
.navigationTitle("Gassi-Wetter")
.navigationBarTitleDisplayMode(.inline)
- .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)
+ .task { location.request() }
+ .onChange(of: location.coordinate?.latitude) { _, _ in
+ Task { await loadWeather() }
}
+ .refreshable { await loadWeather() }
}
private func content(weather: Weather) -> some View {
@@ -68,18 +46,6 @@ 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]
@@ -455,7 +421,8 @@ struct WetterView: View {
}
}
- private func loadWeather(coord: CLLocationCoordinate2D) async {
+ private func loadWeather() async {
+ guard let coord = location.coordinate else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
@@ -463,25 +430,9 @@ struct WetterView: View {
do {
weather = try await WeatherService.shared.weather(for: loc)
} catch {
- errorMessage = Self.isOfflineError(error)
- ? "Wetter ist offline nicht verfügbar. Die Vorhersage lädt automatisch, sobald du wieder Internet hast."
- : error.localizedDescription
+ errorMessage = 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
deleted file mode 100644
index 93071dd..0000000
--- a/BanYaroGoWidget.entitlements
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- com.apple.security.application-groups
-
- group.app.banyaro.ios
-
-
-
diff --git a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
index 1126b63..318e7f0 100644
--- a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
+++ b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
@@ -5,6 +5,5 @@ import SwiftUI
struct BanYaroGoWidgetBundle: WidgetBundle {
var body: some Widget {
WalkLiveActivity()
- BanYaroHomeWidget()
}
}
diff --git a/BanYaroGoWidget/BanYaroHomeWidget.swift b/BanYaroGoWidget/BanYaroHomeWidget.swift
deleted file mode 100644
index 8960237..0000000
--- a/BanYaroGoWidget/BanYaroHomeWidget.swift
+++ /dev/null
@@ -1,88 +0,0 @@
-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
deleted file mode 100644
index 9d32037..0000000
--- a/BanYaroGoWidget/HomeWidgetData.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-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)
- }
-}