Phase 3.6: B+C+D komplett + HealthKit Sync
D.10 401-Handling: APIError.unauthorized, NotificationCenter-Bridge, AuthSession.logout() bei 401 → User landet wieder im Login D.12 PWA-Deep-Links: Settings-Section mit Forum/Hunde/Walks/Settings öffnet Safari per https://banyaro.app/#fragment B.4 Auto-Pause: 2-min-Inaktivität → isAutoPaused, automatischer Resume bei nächstem GPS-Update. Settings-Toggle, im UI eigenes Badge "Auto-Pause" (grau vs. Pause orange). C.7 Edit/Delete: RouteUpdateBody + APIClient.patch + APIClient.delete, EditRouteSheet (Name/Beschreibung/Public), Menu in Toolbar (nur eigene Touren), Alert für Delete. C.9 Statistik-Tab: neuer Tab "Statistik" zwischen Hunde und Mehr. Filtert /api/routes auf meine Touren, rechnet Woche/Monat/Allzeit (Distanz, Dauer, Touren), Längste Tour, aktuelle Streak (Tage in Folge). B.5 Walk-Review: Map-Header an die Spitze des FinishWalkSheet-Forms. B.6 Geo-Fotos: CapturedPhoto (Data + GPSPoint?), PhotoLocation @Model in SwiftData. Kamera während Walk taggt mit tracker.points.last. Nach Upload: foto_url aus Response → PhotoLocation persistiert. MiniRouteMap rendert Annotations mit Tap-Callback, PhotoViewerSheet zeigt Foto fullscreen. C.8 Share PNG+GPX: RouteShareImage (MKMapSnapshotter + Polyline overlay + SwiftUI ShareCard via ImageRenderer), GPXExporter (Tempfile mit XML), ShareSheet (UIActivityViewController-Wrapper), Menu in Route-Toolbar. D.11 Icon-Varianten: AppIcon-Dark (0.45 Brightness), AppIcon-Tinted (Grayscale + Kontrastverstärkung), Contents.json mit appearance entries. A.2 HealthKit: BanYaroGo.entitlements (com.apple.developer.healthkit), NSHealthShare/UpdateUsageDescription. WalkHealthSync.shared mit HKWorkoutBuilder (.walking) + HKWorkoutRouteBuilder, Timestamps gleichmäßig über Walk-Dauer verteilt. Settings-Toggle mit Permission-Request.
This commit is contained in:
parent
30e0fbe7ec
commit
c01e3d6be7
26 changed files with 978 additions and 28 deletions
47
BanYaroGo/Support/GPXExporter.swift
Normal file
47
BanYaroGo/Support/GPXExporter.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Foundation
|
||||
|
||||
enum GPXExporter {
|
||||
/// Writes the route to a temporary `.gpx` file and returns its URL.
|
||||
static func write(detail: RouteDetail) -> URL? {
|
||||
let xml = generate(detail: detail)
|
||||
let safeName = detail.name
|
||||
.replacingOccurrences(of: "/", with: "-")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let filename = safeName.isEmpty ? "tour" : safeName
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("\(filename).gpx")
|
||||
do {
|
||||
try xml.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func generate(detail: RouteDetail) -> String {
|
||||
let trkpts = detail.gpsTrack.map { p -> String in
|
||||
if let alt = p.alt {
|
||||
return " <trkpt lat=\"\(p.lat)\" lon=\"\(p.lon)\"><ele>\(alt)</ele></trkpt>"
|
||||
} else {
|
||||
return " <trkpt lat=\"\(p.lat)\" lon=\"\(p.lon)\"/>"
|
||||
}
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let safeName = detail.name
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
|
||||
return """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Ban Yaro Go" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk>
|
||||
<name>\(safeName)</name>
|
||||
<trkseg>
|
||||
\(trkpts)
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
"""
|
||||
}
|
||||
}
|
||||
112
BanYaroGo/Support/RouteShareImage.swift
Normal file
112
BanYaroGo/Support/RouteShareImage.swift
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import SwiftUI
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
/// Renders a sharable card with map snapshot + stats for a route.
|
||||
@MainActor
|
||||
enum RouteShareImage {
|
||||
static func render(detail: RouteDetail, size: CGFloat = 1200) async -> UIImage? {
|
||||
let mapSize = CGSize(width: size, height: size * 0.75)
|
||||
guard let mapImage = await mapSnapshot(track: detail.gpsTrack, size: mapSize) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let card = ShareCard(detail: detail, mapImage: mapImage, width: size)
|
||||
let renderer = ImageRenderer(content: card)
|
||||
renderer.scale = 2
|
||||
return renderer.uiImage
|
||||
}
|
||||
|
||||
private static func mapSnapshot(track: [GPSPoint], size: CGSize) async -> UIImage? {
|
||||
guard track.count >= 2 else { return nil }
|
||||
let coords = track.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }
|
||||
|
||||
let options = MKMapSnapshotter.Options()
|
||||
options.size = size
|
||||
options.pointOfInterestFilter = .excludingAll
|
||||
let lats = track.map(\.lat)
|
||||
let lons = track.map(\.lon)
|
||||
options.region = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(
|
||||
latitude: (lats.min()! + lats.max()!) / 2,
|
||||
longitude: (lons.min()! + lons.max()!) / 2
|
||||
),
|
||||
span: MKCoordinateSpan(
|
||||
latitudeDelta: max((lats.max()! - lats.min()!) * 1.4, 0.002),
|
||||
longitudeDelta: max((lons.max()! - lons.min()!) * 1.4, 0.002)
|
||||
)
|
||||
)
|
||||
|
||||
do {
|
||||
let snapshot = try await MKMapSnapshotter(options: options).start()
|
||||
let uiRenderer = UIGraphicsImageRenderer(size: size)
|
||||
return uiRenderer.image { ctx in
|
||||
snapshot.image.draw(in: CGRect(origin: .zero, size: size))
|
||||
ctx.cgContext.setLineWidth(6)
|
||||
ctx.cgContext.setStrokeColor(UIColor(red: 0xC4 / 255.0, green: 0x84 / 255.0, blue: 0x3A / 255.0, alpha: 1).cgColor)
|
||||
ctx.cgContext.setLineJoin(.round)
|
||||
ctx.cgContext.setLineCap(.round)
|
||||
var first = true
|
||||
for coord in coords {
|
||||
let p = snapshot.point(for: coord)
|
||||
if first { ctx.cgContext.move(to: p); first = false }
|
||||
else { ctx.cgContext.addLine(to: p) }
|
||||
}
|
||||
ctx.cgContext.strokePath()
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShareCard: View {
|
||||
let detail: RouteDetail
|
||||
let mapImage: UIImage
|
||||
let width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Image(uiImage: mapImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(detail.name)
|
||||
.font(.system(size: 36, weight: .bold))
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 28) {
|
||||
stat(value: String(format: "%.2f km", detail.distanzKm ?? 0), label: "Distanz")
|
||||
stat(value: durationLabel, label: "Dauer")
|
||||
stat(value: "\(detail.gpsTrack.count)", label: "Punkte")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "pawprint.fill")
|
||||
.foregroundStyle(Color(red: 0xC4 / 255.0, green: 0x84 / 255.0, blue: 0x3A / 255.0))
|
||||
Text("Ban Yaro Go")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.background(Color.white)
|
||||
}
|
||||
.frame(width: width)
|
||||
.background(Color.white)
|
||||
}
|
||||
|
||||
private func stat(value: String, label: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(value).font(.system(size: 28, weight: .semibold))
|
||||
Text(label).font(.system(size: 16)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var durationLabel: String {
|
||||
let mins = detail.dauerMin ?? 0
|
||||
if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" }
|
||||
return "\(mins) min"
|
||||
}
|
||||
}
|
||||
85
BanYaroGo/Support/WalkHealthSync.swift
Normal file
85
BanYaroGo/Support/WalkHealthSync.swift
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import Foundation
|
||||
import HealthKit
|
||||
import CoreLocation
|
||||
|
||||
/// Saves a completed walk as an HKWorkout(.walking) with HKWorkoutRoute so it
|
||||
/// shows up in Apple Health and counts toward the user's Activity rings.
|
||||
@MainActor
|
||||
final class WalkHealthSync {
|
||||
static let shared = WalkHealthSync()
|
||||
private let store = HKHealthStore()
|
||||
|
||||
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
||||
|
||||
/// Requests share authorization for workouts + workout routes.
|
||||
@discardableResult
|
||||
func requestAuthorization() async -> Bool {
|
||||
guard isAvailable else { return false }
|
||||
let types: Set<HKSampleType> = [
|
||||
HKObjectType.workoutType(),
|
||||
HKSeriesType.workoutRoute()
|
||||
]
|
||||
do {
|
||||
try await store.requestAuthorization(toShare: types, read: [])
|
||||
return true
|
||||
} catch {
|
||||
print("WalkHealthSync auth failed: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func saveWalk(
|
||||
points: [GPSPoint],
|
||||
startedAt: Date,
|
||||
endedAt: Date,
|
||||
distanceMeters: Double
|
||||
) async {
|
||||
guard isAvailable, points.count >= 2 else { return }
|
||||
|
||||
let configuration = HKWorkoutConfiguration()
|
||||
configuration.activityType = .walking
|
||||
configuration.locationType = .outdoor
|
||||
|
||||
let builder = HKWorkoutBuilder(
|
||||
healthStore: store,
|
||||
configuration: configuration,
|
||||
device: .local()
|
||||
)
|
||||
|
||||
do {
|
||||
try await builder.beginCollection(at: startedAt)
|
||||
|
||||
let distanceQuantity = HKQuantity(unit: .meter(), doubleValue: distanceMeters)
|
||||
let distanceSample = HKQuantitySample(
|
||||
type: HKQuantityType(.distanceWalkingRunning),
|
||||
quantity: distanceQuantity,
|
||||
start: startedAt,
|
||||
end: endedAt
|
||||
)
|
||||
try await builder.addSamples([distanceSample])
|
||||
|
||||
try await builder.endCollection(at: endedAt)
|
||||
guard let workout = try await builder.finishWorkout() else { return }
|
||||
|
||||
// Distribute timestamps evenly across the recorded period — our
|
||||
// GPSPoint doesn't carry per-point timing.
|
||||
let totalSeconds = endedAt.timeIntervalSince(startedAt)
|
||||
let interval = totalSeconds / Double(max(1, points.count - 1))
|
||||
let locations = points.enumerated().map { i, p in
|
||||
CLLocation(
|
||||
coordinate: CLLocationCoordinate2D(latitude: p.lat, longitude: p.lon),
|
||||
altitude: p.alt ?? 0,
|
||||
horizontalAccuracy: 5,
|
||||
verticalAccuracy: 5,
|
||||
timestamp: startedAt.addingTimeInterval(Double(i) * interval)
|
||||
)
|
||||
}
|
||||
|
||||
let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local())
|
||||
try await routeBuilder.insertRouteData(locations)
|
||||
_ = try await routeBuilder.finishRoute(with: workout, metadata: nil)
|
||||
} catch {
|
||||
print("WalkHealthSync save failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue