Compare commits
5 commits
9e51f3910e
...
bc74926431
| Author | SHA1 | Date | |
|---|---|---|---|
| bc74926431 | |||
| 5ea219d274 | |||
| e6210978f7 | |||
| d807db57a2 | |||
| a2646a18ef |
27 changed files with 1086 additions and 231 deletions
|
|
@ -8,5 +8,9 @@
|
||||||
<array/>
|
<array/>
|
||||||
<key>com.apple.developer.weatherkit</key>
|
<key>com.apple.developer.weatherkit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.app.banyaro.ios</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
|
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = H436BR6YWX;
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
|
@ -369,7 +369,7 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
|
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = H436BR6YWX;
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
|
@ -391,8 +391,9 @@
|
||||||
BB0000000000000000000031 /* Debug */ = {
|
BB0000000000000000000031 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = H436BR6YWX;
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
|
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
|
||||||
|
|
@ -415,8 +416,9 @@
|
||||||
BB0000000000000000000032 /* Release */ = {
|
BB0000000000000000000032 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = H436BR6YWX;
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
|
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ struct DiaryEntry: Decodable, Identifiable {
|
||||||
var isMilestoneFlag: Bool { isMilestone == 1 }
|
var isMilestoneFlag: Bool { isMilestone == 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DiaryMedia: Decodable, Identifiable {
|
struct DiaryMedia: Codable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let url: String
|
let url: String
|
||||||
let mediaType: String?
|
let mediaType: String?
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BanYaroGoApp: App {
|
struct BanYaroGoApp: App {
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var auth = AuthSession()
|
@State private var auth = AuthSession()
|
||||||
@State private var activeDog = ActiveDogStore()
|
@State private var activeDog = ActiveDogStore()
|
||||||
@State private var pendingGPX: GPXTrack?
|
@State private var pendingGPX: GPXTrack?
|
||||||
|
|
@ -12,6 +13,9 @@ struct BanYaroGoApp: App {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
.environment(activeDog)
|
.environment(activeDog)
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
if phase == .active { WalkLauncher.shared.consumePendingFlag() }
|
||||||
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleIncoming(url: url)
|
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) {
|
private func handleIncoming(url: URL) {
|
||||||
|
|
|
||||||
33
BanYaroGo/Support/HomeWidgetData.swift
Normal file
33
BanYaroGo/Support/HomeWidgetData.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
293
BanYaroGo/Support/OfflineCache.swift
Normal file
293
BanYaroGo/Support/OfflineCache.swift
Normal file
|
|
@ -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<T: Encodable>(_ value: T) -> Data? { try? encoder.encode(value) }
|
||||||
|
static func decode<T: Decodable>(_ 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<CachedRoute>(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<CachedRoute>(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<CachedRoute>(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<CachedRoute>(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<CachedDiaryEntry>(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<CachedDiaryEntry>(
|
||||||
|
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<CachedImage>(predicate: #Predicate { $0.path == path })).first)?.data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storeImage(path: String, data: Data, in ctx: ModelContext) {
|
||||||
|
if let existing = try? ctx.fetch(FetchDescriptor<CachedImage>(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<CachedImage>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,26 @@ import Observation
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
/// Asks CLLocationManager for the user's current location once. Used by
|
/// 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
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OneShotLocation: NSObject, CLLocationManagerDelegate {
|
final class OneShotLocation: NSObject, CLLocationManagerDelegate {
|
||||||
private let manager = CLLocationManager()
|
private let manager = CLLocationManager()
|
||||||
|
private var timeoutTask: Task<Void, Never>?
|
||||||
|
private let timeoutSeconds: Double = 10
|
||||||
|
private var continuation: CheckedContinuation<CLLocationCoordinate2D?, Never>?
|
||||||
|
|
||||||
var coordinate: CLLocationCoordinate2D?
|
var coordinate: CLLocationCoordinate2D?
|
||||||
var error: String?
|
var error: String?
|
||||||
|
|
@ -19,20 +34,61 @@ final class OneShotLocation: NSObject, CLLocationManagerDelegate {
|
||||||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beobachtbarer Weg (Giftköder): startet die Auflösung, Ergebnis landet in
|
||||||
|
/// `coordinate`/`error`.
|
||||||
func request() {
|
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
|
error = nil
|
||||||
isResolving = true
|
isResolving = true
|
||||||
switch manager.authorizationStatus {
|
return await withCheckedContinuation { (cont: CheckedContinuation<CLLocationCoordinate2D?, Never>) in
|
||||||
case .notDetermined:
|
self.continuation = cont
|
||||||
manager.requestWhenInUseAuthorization()
|
self.startTimeout()
|
||||||
case .denied, .restricted:
|
switch self.manager.authorizationStatus {
|
||||||
error = "Standortzugriff verweigert."
|
case .notDetermined:
|
||||||
isResolving = false
|
self.manager.requestWhenInUseAuthorization()
|
||||||
case .authorizedWhenInUse, .authorizedAlways:
|
case .denied, .restricted:
|
||||||
manager.requestLocation()
|
self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
|
||||||
@unknown default:
|
case .authorizedWhenInUse, .authorizedAlways:
|
||||||
error = "Unbekannter Standort-Status."
|
self.manager.requestLocation()
|
||||||
isResolving = false
|
@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,
|
_ manager: CLLocationManager,
|
||||||
didUpdateLocations locations: [CLLocation]
|
didUpdateLocations locations: [CLLocation]
|
||||||
) {
|
) {
|
||||||
guard let loc = locations.first else { return }
|
guard let c = locations.first?.coordinate else { return }
|
||||||
let c = loc.coordinate
|
Task { @MainActor in self.complete(coord: c, errorText: nil) }
|
||||||
Task { @MainActor in
|
|
||||||
self.coordinate = c
|
|
||||||
self.isResolving = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) {
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError err: Error) {
|
||||||
let msg = err.localizedDescription
|
let msg = err.localizedDescription
|
||||||
Task { @MainActor in
|
Task { @MainActor in self.complete(coord: nil, errorText: msg) }
|
||||||
self.error = msg
|
|
||||||
self.isResolving = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
let status = manager.authorizationStatus
|
let status = manager.authorizationStatus
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard self.continuation != nil else { return }
|
||||||
switch status {
|
switch status {
|
||||||
case .authorizedWhenInUse, .authorizedAlways:
|
case .authorizedWhenInUse, .authorizedAlways:
|
||||||
manager.requestLocation()
|
manager.requestLocation()
|
||||||
case .denied, .restricted:
|
case .denied, .restricted:
|
||||||
self.error = "Standortzugriff verweigert."
|
self.complete(coord: nil, errorText: "Standortzugriff verweigert.")
|
||||||
self.isResolving = false
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
BanYaroGo/Support/Outbox.swift
Normal file
112
BanYaroGo/Support/Outbox.swift
Normal file
|
|
@ -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<PendingRoute>())) ?? 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<PendingRoute>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
32
BanYaroGo/Support/StartWalkIntent.swift
Normal file
32
BanYaroGo/Support/StartWalkIntent.swift
Normal file
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
BanYaroGo/Support/WalkLauncher.swift
Normal file
33
BanYaroGo/Support/WalkLauncher.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
51
BanYaroGo/Views/CachedAsyncImage.swift
Normal file
51
BanYaroGo/Views/CachedAsyncImage.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Wie `AsyncImage`, aber offline-fähig: zeigt zuerst ein lokal gecachtes Bild
|
||||||
|
/// (CachedImage in SwiftData); fehlt es, wird es remote geladen und dabei gleich
|
||||||
|
/// in den Offline-Cache geschrieben. `path` ist der relative Medienpfad
|
||||||
|
/// (z. B. "/media/routes/x.jpg"), die Basis-URL hängt OfflineCache an.
|
||||||
|
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||||
|
private let path: String?
|
||||||
|
private let content: (Image) -> Content
|
||||||
|
private let placeholder: () -> Placeholder
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@State private var uiImage: UIImage?
|
||||||
|
|
||||||
|
init(
|
||||||
|
path: String?,
|
||||||
|
@ViewBuilder content: @escaping (Image) -> Content,
|
||||||
|
@ViewBuilder placeholder: @escaping () -> Placeholder
|
||||||
|
) {
|
||||||
|
self.path = path
|
||||||
|
self.content = content
|
||||||
|
self.placeholder = placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let uiImage {
|
||||||
|
content(Image(uiImage: uiImage))
|
||||||
|
} else {
|
||||||
|
placeholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: path) { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
uiImage = nil
|
||||||
|
guard let path, !path.isEmpty else { return }
|
||||||
|
if let data = OfflineCache.imageData(path: path, in: ctx), let img = UIImage(data: data) {
|
||||||
|
uiImage = img
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url = URL(string: OfflineCache.mediaBase + path) else { return }
|
||||||
|
if let (data, _) = try? await URLSession.shared.data(from: url), let img = UIImage(data: data) {
|
||||||
|
uiImage = img
|
||||||
|
OfflineCache.storeImage(path: path, data: data, in: ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -91,14 +91,10 @@ struct DiaryDetailView: View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
TabView(selection: $photoIndex) {
|
TabView(selection: $photoIndex) {
|
||||||
ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in
|
ForEach(Array(media.enumerated()), id: \.element.id) { idx, m in
|
||||||
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in
|
CachedAsyncImage(path: m.url) { img in
|
||||||
switch phase {
|
img.resizable().scaledToFit()
|
||||||
case .success(let img): img.resizable().scaledToFit()
|
} placeholder: {
|
||||||
case .failure: Image(systemName: "photo")
|
ProgressView()
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
default: ProgressView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.tag(idx)
|
.tag(idx)
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct DogsListView: View {
|
|
||||||
@State private var dogs: [Dog] = []
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var errorMessage: String?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
content
|
|
||||||
.navigationTitle("Hunde")
|
|
||||||
.task { await load() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var content: some View {
|
|
||||||
if isLoading && dogs.isEmpty {
|
|
||||||
ProgressView()
|
|
||||||
} else if let error = errorMessage, dogs.isEmpty {
|
|
||||||
ContentUnavailableView(
|
|
||||||
"Konnte Hunde nicht laden",
|
|
||||||
systemImage: "wifi.slash",
|
|
||||||
description: Text(error)
|
|
||||||
)
|
|
||||||
} else if dogs.isEmpty {
|
|
||||||
ContentUnavailableView(
|
|
||||||
"Keine Hunde",
|
|
||||||
systemImage: "pawprint",
|
|
||||||
description: Text("Lege deinen ersten Hund in der PWA an.")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
List(dogs) { dog in
|
|
||||||
DogRow(dog: dog)
|
|
||||||
}
|
|
||||||
.refreshable { await load() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func load() async {
|
|
||||||
isLoading = true
|
|
||||||
errorMessage = nil
|
|
||||||
defer { isLoading = false }
|
|
||||||
do {
|
|
||||||
dogs = try await APIClient.shared.get("/api/dogs")
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DogRow: View {
|
|
||||||
let dog: Dog
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
avatar
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.background(.background.secondary)
|
|
||||||
.clipShape(Circle())
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(dog.name).font(.headline)
|
|
||||||
if let rasse = dog.rasse, !rasse.isEmpty {
|
|
||||||
Text(rasse)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var avatar: some View {
|
|
||||||
if let path = dog.fotoUrl, let url = URL(string: "https://banyaro.app\(path)") {
|
|
||||||
AsyncImage(url: url) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let img):
|
|
||||||
img.resizable().scaledToFill()
|
|
||||||
default:
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholder: some View {
|
|
||||||
Image(systemName: "pawprint.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,6 +25,7 @@ struct FinishWalkSheet: View {
|
||||||
|
|
||||||
@State private var saveState: SaveState = .idle
|
@State private var saveState: SaveState = .idle
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var savedOffline = false
|
||||||
|
|
||||||
private enum SaveState: Equatable {
|
private enum SaveState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
|
|
@ -144,7 +145,15 @@ struct FinishWalkSheet: View {
|
||||||
.onChange(of: photoSelection) { _, newItems in
|
.onChange(of: photoSelection) { _, newItems in
|
||||||
Task { await loadPhotos(from: newItems) }
|
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 {
|
do {
|
||||||
route = try await APIClient.shared.post("/api/routes", body: body)
|
route = try await APIClient.shared.post("/api/routes", body: body)
|
||||||
} catch {
|
} 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
|
errorMessage = error.localizedDescription
|
||||||
saveState = .idle
|
saveState = .idle
|
||||||
return
|
return
|
||||||
|
|
@ -321,19 +339,21 @@ struct FinishWalkSheet: View {
|
||||||
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
|
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Health sync (only if user opted in)
|
await syncHealthIfEnabled()
|
||||||
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()
|
onSaved()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncHealthIfEnabled() async {
|
||||||
|
guard healthKitSyncEnabled, points.count >= 2 else { return }
|
||||||
|
let endedAt = Date.now
|
||||||
|
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
|
||||||
|
await WalkHealthSync.shared.saveWalk(
|
||||||
|
points: points,
|
||||||
|
startedAt: startedAt,
|
||||||
|
endedAt: endedAt,
|
||||||
|
distanceMeters: distanceMeters
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct HeimView: View {
|
struct HeimView: View {
|
||||||
@Environment(AuthSession.self) private var auth
|
@Environment(AuthSession.self) private var auth
|
||||||
|
|
@ -260,5 +262,42 @@ struct HeimView: View {
|
||||||
}
|
}
|
||||||
cachedPhotoUrl = fresh
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,38 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@Environment(AuthSession.self) private var auth
|
@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 {
|
var body: some View {
|
||||||
TabView {
|
TabView(selection: $selectedTab) {
|
||||||
HeimView()
|
HeimView()
|
||||||
.tabItem { Label("Heim", systemImage: "house.fill") }
|
.tabItem { Label("Heim", systemImage: "house.fill") }
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
RoutesListView()
|
RoutesListView()
|
||||||
.tabItem { Label("Touren", systemImage: "map.fill") }
|
.tabItem { Label("Touren", systemImage: "map.fill") }
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
TrackingView()
|
TrackingView()
|
||||||
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
|
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
|
.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() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,33 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(AuthSession.self) private var auth
|
@Environment(AuthSession.self) private var auth
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if auth.isLoggedIn {
|
Group {
|
||||||
MainTabView()
|
if auth.isLoggedIn {
|
||||||
} else {
|
MainTabView()
|
||||||
LoginView()
|
} 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ struct RouteDetailView: View {
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(AuthSession.self) private var auth
|
@Environment(AuthSession.self) private var auth
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
@Query private var allPhotoLocations: [PhotoLocation]
|
@Query private var allPhotoLocations: [PhotoLocation]
|
||||||
|
|
||||||
@State private var detail: RouteDetail?
|
@State private var detail: RouteDetail?
|
||||||
|
|
@ -218,27 +219,32 @@ struct RouteDetailView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func photoThumb(_ path: String) -> some View {
|
private func photoThumb(_ path: String) -> some View {
|
||||||
let url = URL(string: "https://banyaro.app\(path)")
|
CachedAsyncImage(path: path) { img in
|
||||||
return AsyncImage(url: url) { phase in
|
img.resizable().scaledToFill()
|
||||||
switch phase {
|
} placeholder: {
|
||||||
case .success(let img):
|
Rectangle().fill(.gray.opacity(0.15))
|
||||||
img.resizable().scaledToFill()
|
|
||||||
default:
|
|
||||||
Rectangle().fill(.gray.opacity(0.15))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(width: 160, height: 160)
|
.frame(width: 160, height: 160)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
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
|
errorMessage = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
// 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
|
||||||
do {
|
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 {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
if detail == nil { errorMessage = error.localizedDescription }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,17 +313,11 @@ private struct PhotoViewerSheet: View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
if let url = URL(string: "https://banyaro.app\(path)") {
|
if !path.isEmpty {
|
||||||
AsyncImage(url: url) { phase in
|
CachedAsyncImage(path: path) { img in
|
||||||
switch phase {
|
img.resizable().scaledToFit()
|
||||||
case .success(let img):
|
} placeholder: {
|
||||||
img.resizable().scaledToFit()
|
ProgressView().tint(.white)
|
||||||
case .failure:
|
|
||||||
ContentUnavailableView("Foto nicht ladbar", systemImage: "photo.badge.exclamationmark")
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
default:
|
|
||||||
ProgressView().tint(.white)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct RoutesListView: View {
|
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 routes: [RouteListItem] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
@ -15,26 +18,39 @@ struct RoutesListView: View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
if isLoading && routes.isEmpty {
|
if isLoading && routes.isEmpty && pending.isEmpty {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else if let error = errorMessage, routes.isEmpty {
|
} else if let error = errorMessage, routes.isEmpty && pending.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Konnte Touren nicht laden",
|
"Konnte Touren nicht laden",
|
||||||
systemImage: "wifi.slash",
|
systemImage: "wifi.slash",
|
||||||
description: Text(error)
|
description: Text(error)
|
||||||
)
|
)
|
||||||
} else if routes.isEmpty {
|
} else if routes.isEmpty && pending.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Keine Touren",
|
"Keine Touren",
|
||||||
systemImage: "map",
|
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 {
|
} else {
|
||||||
List(routes) { route in
|
List {
|
||||||
NavigationLink {
|
if !pending.isEmpty {
|
||||||
RouteDetailView(routeId: route.id, fallbackName: route.name)
|
Section {
|
||||||
} label: {
|
ForEach(pending) { p in
|
||||||
RouteRowView(route: route)
|
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() }
|
.refreshable { await load() }
|
||||||
|
|
@ -42,13 +58,25 @@ struct RoutesListView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
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
|
errorMessage = nil
|
||||||
defer { isLoading = false }
|
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 {
|
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 {
|
} 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 {
|
enum DateUtil {
|
||||||
/// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601)
|
/// Parses backend timestamps (SQLite `YYYY-MM-DD HH:MM:SS` or ISO-8601)
|
||||||
/// into a German short date.
|
/// into a German short date.
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
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 {
|
Section {
|
||||||
if let url = URL(string: "https://banyaro.app") {
|
if let url = URL(string: "https://banyaro.app") {
|
||||||
Link(destination: url) {
|
Link(destination: url) {
|
||||||
Label("banyaro.app in Safari öffnen", systemImage: "safari.fill")
|
HStack {
|
||||||
.foregroundStyle(.primary)
|
Label("banyaro.app öffnen", systemImage: "safari.fill")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("banyaro.app als Web-App")
|
Text("banyaro.app")
|
||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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.")
|
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) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text("**1.**")
|
Text("**1.**")
|
||||||
Text("Oben „in Safari öffnen“ tippen")
|
Text("Oben „banyaro.app öffnen“ tippen")
|
||||||
}
|
}
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text("**2.**")
|
Text("**2.**")
|
||||||
|
|
@ -170,7 +169,7 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Über") {
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -261,20 +260,4 @@ struct SettingsView: View {
|
||||||
if path.hasPrefix("http") { return URL(string: path) }
|
if path.hasPrefix("http") { return URL(string: path) }
|
||||||
return URL(string: "https://banyaro.app\(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct TagebuchView: View {
|
struct TagebuchView: View {
|
||||||
@Environment(ActiveDogStore.self) private var activeDog
|
@Environment(ActiveDogStore.self) private var activeDog
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
|
||||||
@State private var entries: [DiaryEntry] = []
|
@State private var entries: [DiaryEntry] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
|
@ -66,16 +68,26 @@ struct TagebuchView: View {
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
guard let dog = activeDog.activeDog else { return }
|
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
|
errorMessage = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
// 2) Netzwerk → Cache aktualisieren; bei Fehler bleibt der Cache stehen
|
||||||
do {
|
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 {
|
} catch let decodingError as DecodingError {
|
||||||
errorMessage = Self.describe(decodingError)
|
if entries.isEmpty { errorMessage = Self.describe(decodingError) }
|
||||||
print("Tagebuch decode error: \(decodingError)")
|
print("Tagebuch decode error: \(decodingError)")
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
if entries.isEmpty { errorMessage = error.localizedDescription }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,11 +143,10 @@ private struct DiaryRow: View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(media) { m in
|
ForEach(media) { m in
|
||||||
AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in
|
CachedAsyncImage(path: m.url) { img in
|
||||||
switch phase {
|
img.resizable().scaledToFill()
|
||||||
case .success(let img): img.resizable().scaledToFill()
|
} placeholder: {
|
||||||
default: Rectangle().fill(.gray.opacity(0.15))
|
Rectangle().fill(.gray.opacity(0.15))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ struct TrackingView: View {
|
||||||
@State private var showCamera = false
|
@State private var showCamera = false
|
||||||
@State private var showResumeDialog = false
|
@State private var showResumeDialog = false
|
||||||
@State private var didCheckResume = 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 clockTicker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
private let persistTicker = Timer.publish(every: 5, 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.isPaused) { _, _ in updateLiveActivity() }
|
||||||
.onChange(of: tracker.isAutoPaused) { _, _ 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) {
|
.sheet(isPresented: $showFinishSheet) {
|
||||||
FinishWalkSheet(
|
FinishWalkSheet(
|
||||||
points: tracker.points,
|
points: tracker.points,
|
||||||
|
|
@ -269,6 +276,14 @@ struct TrackingView: View {
|
||||||
tracker.startOrRequest()
|
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() {
|
private func offerResumeIfNeeded() {
|
||||||
guard !didCheckResume else { return }
|
guard !didCheckResume else { return }
|
||||||
didCheckResume = true
|
didCheckResume = true
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ struct WetterView: View {
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var selectedDayIndex = 0
|
@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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
|
|
@ -33,11 +43,23 @@ struct WetterView: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Gassi-Wetter")
|
.navigationTitle("Gassi-Wetter")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task { location.request() }
|
.task {
|
||||||
.onChange(of: location.coordinate?.latitude) { _, _ in
|
// Deterministisch: erst Standort auflösen (Fix/Fehler/Timeout),
|
||||||
Task { await loadWeather() }
|
// 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 {
|
private func content(weather: Weather) -> some View {
|
||||||
|
|
@ -46,6 +68,18 @@ struct WetterView: View {
|
||||||
|
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 14) {
|
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 {
|
if !days.isEmpty {
|
||||||
dayPicker(days: days)
|
dayPicker(days: days)
|
||||||
let day = days[safeIndex]
|
let day = days[safeIndex]
|
||||||
|
|
@ -421,8 +455,7 @@ struct WetterView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadWeather() async {
|
private func loadWeather(coord: CLLocationCoordinate2D) async {
|
||||||
guard let coord = location.coordinate else { return }
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
@ -430,9 +463,25 @@ struct WetterView: View {
|
||||||
do {
|
do {
|
||||||
weather = try await WeatherService.shared.weather(for: loc)
|
weather = try await WeatherService.shared.weather(for: loc)
|
||||||
} catch {
|
} 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
|
// MARK: - Day metrics derived from DayWeather
|
||||||
|
|
|
||||||
10
BanYaroGoWidget.entitlements
Normal file
10
BanYaroGoWidget.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.app.banyaro.ios</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -5,5 +5,6 @@ import SwiftUI
|
||||||
struct BanYaroGoWidgetBundle: WidgetBundle {
|
struct BanYaroGoWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
WalkLiveActivity()
|
WalkLiveActivity()
|
||||||
|
BanYaroHomeWidget()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
BanYaroGoWidget/BanYaroHomeWidget.swift
Normal file
88
BanYaroGoWidget/BanYaroHomeWidget.swift
Normal file
|
|
@ -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<BanYaroEntry>) -> 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
22
BanYaroGoWidget/HomeWidgetData.swift
Normal file
22
BanYaroGoWidget/HomeWidgetData.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue