1.1: Home-Screen-Widget + Siri-Kurzbefehl „Gassi gehen"

App Group group.app.banyaro.ios verbindet App und Widget-Extension
(Entitlements in beiden Targets, CODE_SIGN_ENTITLEMENTS fürs Widget).

Home-Screen-Widget (D):
- BanYaroHomeWidget (klein + mittel): Tagesfoto, Hundename, nächster Termin.
- App schreibt beim Heim-Laden einen Snapshot (HomeWidgetData) in die App
  Group und triggert WidgetCenter-Reload; Snapshot wird bei Logout/401 geleert.

Siri-/Kurzbefehl (E):
- StartWalkIntent „Gassi gehen" + AppShortcutsProvider (öffnet die App).
- WalkLauncher überbrückt Intent → UI: Flag in der App Group, beim Aktivwerden
  eingelöst → Aufnehmen-Tab + Aufnahme-Start (TrackingView.startFresh).
- MainTabView mit Tab-Auswahl (Tags), BanYaroGoApp liest scenePhase.
This commit is contained in:
rene 2026-06-02 20:01:16 +02:00
parent a2646a18ef
commit d807db57a2
14 changed files with 307 additions and 6 deletions

View file

@ -3,6 +3,7 @@ import SwiftData
@main
struct BanYaroGoApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var auth = AuthSession()
@State private var activeDog = ActiveDogStore()
@State private var pendingGPX: GPXTrack?
@ -12,6 +13,9 @@ struct BanYaroGoApp: App {
RootView()
.environment(auth)
.environment(activeDog)
.onChange(of: scenePhase) { _, phase in
if phase == .active { WalkLauncher.shared.consumePendingFlag() }
}
.onOpenURL { url in
handleIncoming(url: url)
}

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

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

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

View file

@ -1,4 +1,6 @@
import SwiftUI
import WidgetKit
import UIKit
struct HeimView: View {
@Environment(AuthSession.self) private var auth
@ -260,5 +262,42 @@ struct HeimView: View {
}
cachedPhotoUrl = fresh
}
await updateWidgetSnapshot(dog: dog)
}
/// Schreibt einen Snapshot (kleines Foto + nächster Termin) in die App Group
/// fürs Home-Screen-Widget und triggert ein Widget-Reload.
private func updateWidgetSnapshot(dog: Dog) async {
var photoJPEG: Data?
if let path = dashboard?.randomPhoto?.previewUrl ?? dashboard?.randomPhoto?.url,
let url = URL(string: "https://banyaro.app\(path)"),
let (data, _) = try? await URLSession.shared.data(from: url) {
photoJPEG = Self.widgetThumbnail(from: data)
}
let appt: String? = {
guard let a = dashboard?.nextAppointment, let bez = a.bezeichnung, !bez.isEmpty else { return nil }
if let date = a.naechstes { return "\(bez) · \(DiaryUtil.format(date))" }
return bez
}()
HomeWidgetStore.save(HomeWidgetData(
dogName: dog.name,
photoJPEG: photoJPEG,
nextAppointment: appt,
diaryCount: dashboard?.diaryCount,
updatedAt: Date()
))
WidgetCenter.shared.reloadAllTimelines()
}
private static func widgetThumbnail(from data: Data, max: CGFloat = 600) -> Data? {
guard let img = UIImage(data: data) else { return nil }
let longest = Swift.max(img.size.width, img.size.height)
let scale = longest > max ? max / longest : 1
let size = CGSize(width: img.size.width * scale, height: img.size.height * scale)
let rendered = UIGraphicsImageRenderer(size: size).image { _ in
img.draw(in: CGRect(origin: .zero, size: size))
}
return rendered.jpegData(compressionQuality: 0.7)
}
}

View file

@ -4,25 +4,35 @@ import SwiftData
struct MainTabView: View {
@Environment(AuthSession.self) private var auth
@Environment(\.modelContext) private var ctx
@State private var selectedTab = 0
private let launcher = WalkLauncher.shared
var body: some View {
TabView {
TabView(selection: $selectedTab) {
HeimView()
.tabItem { Label("Heim", systemImage: "house.fill") }
.tag(0)
RoutesListView()
.tabItem { Label("Touren", systemImage: "map.fill") }
.tag(1)
TrackingView()
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
.tag(2)
SettingsView()
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
.tag(3)
}
.task {
await auth.loadProfile()
// Offline gesicherte Touren beim Start hochladen (falls online).
await OfflineCache.syncPendingRoutes(in: ctx)
launcher.consumePendingFlag()
}
.onChange(of: launcher.pendingStart) { _, pending in
if pending { selectedTab = 2 }
}
}
}

View file

@ -1,5 +1,6 @@
import SwiftUI
import SwiftData
import WidgetKit
struct RootView: View {
@Environment(AuthSession.self) private var auth
@ -13,13 +14,20 @@ struct RootView: View {
LoginView()
}
}
// Offline-Cache leeren, sobald der User wechselt (Logout oder 401),
// damit nie Touren/Tagebuch/Fotos eines vorigen Users durchschimmern.
// 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
OfflineCache.clearAll(in: ctx)
clearOnUserChange()
}
.onReceive(NotificationCenter.default.publisher(for: .apiUnauthorized)) { _ in
OfflineCache.clearAll(in: ctx)
clearOnUserChange()
}
}
private func clearOnUserChange() {
OfflineCache.clearAll(in: ctx)
HomeWidgetStore.clear()
WidgetCenter.shared.reloadAllTimelines()
}
}

View file

@ -14,6 +14,7 @@ struct TrackingView: View {
@State private var showCamera = false
@State private var showResumeDialog = false
@State private var didCheckResume = false
private let launcher = WalkLauncher.shared
private let clockTicker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let persistTicker = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
@ -45,7 +46,13 @@ struct TrackingView: View {
}
.onChange(of: tracker.isPaused) { _, _ in updateLiveActivity() }
.onChange(of: tracker.isAutoPaused) { _, _ in updateLiveActivity() }
.onAppear { offerResumeIfNeeded() }
.onAppear {
offerResumeIfNeeded()
startIfRequested()
}
.onChange(of: launcher.pendingStart) { _, pending in
if pending { startIfRequested() }
}
.sheet(isPresented: $showFinishSheet) {
FinishWalkSheet(
points: tracker.points,
@ -269,6 +276,14 @@ struct TrackingView: View {
tracker.startOrRequest()
}
/// Vom Siri-Kurzbefehl Gassi gehen" angestoßen: Aufnahme starten, sofern
/// nicht schon eine läuft.
private func startIfRequested() {
guard launcher.pendingStart, !tracker.isTracking else { return }
launcher.pendingStart = false
startFresh()
}
private func offerResumeIfNeeded() {
guard !didCheckResume else { return }
didCheckResume = true