banyaro-ios/BanYaroGo/Support/RouteShareImage.swift
rene c01e3d6be7 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.
2026-05-30 11:19:53 +02:00

112 lines
4 KiB
Swift

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