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