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) + } +}