diff --git a/BanYaroGo.entitlements b/BanYaroGo.entitlements
index 03d07da..ae2de8f 100644
--- a/BanYaroGo.entitlements
+++ b/BanYaroGo.entitlements
@@ -8,5 +8,9 @@
com.apple.developer.weatherkit
+ com.apple.security.application-groups
+
+ group.app.banyaro.ios
+
diff --git a/BanYaroGo.xcodeproj/project.pbxproj b/BanYaroGo.xcodeproj/project.pbxproj
index 904124b..ca99876 100644
--- a/BanYaroGo.xcodeproj/project.pbxproj
+++ b/BanYaroGo.xcodeproj/project.pbxproj
@@ -391,6 +391,7 @@
BB0000000000000000000031 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
@@ -415,6 +416,7 @@
BB0000000000000000000032 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = BanYaroGoWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift
index 813f87a..72f4602 100644
--- a/BanYaroGo/BanYaroGoApp.swift
+++ b/BanYaroGo/BanYaroGoApp.swift
@@ -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)
}
diff --git a/BanYaroGo/Support/HomeWidgetData.swift b/BanYaroGo/Support/HomeWidgetData.swift
new file mode 100644
index 0000000..541b06a
--- /dev/null
+++ b/BanYaroGo/Support/HomeWidgetData.swift
@@ -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)
+ }
+}
diff --git a/BanYaroGo/Support/StartWalkIntent.swift b/BanYaroGo/Support/StartWalkIntent.swift
new file mode 100644
index 0000000..b16a75d
--- /dev/null
+++ b/BanYaroGo/Support/StartWalkIntent.swift
@@ -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"
+ )
+ }
+}
diff --git a/BanYaroGo/Support/WalkLauncher.swift b/BanYaroGo/Support/WalkLauncher.swift
new file mode 100644
index 0000000..e80152c
--- /dev/null
+++ b/BanYaroGo/Support/WalkLauncher.swift
@@ -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
+ }
+}
diff --git a/BanYaroGo/Views/HeimView.swift b/BanYaroGo/Views/HeimView.swift
index b1f553c..2c08cda 100644
--- a/BanYaroGo/Views/HeimView.swift
+++ b/BanYaroGo/Views/HeimView.swift
@@ -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)
}
}
diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift
index 40b0075..d5ccc5b 100644
--- a/BanYaroGo/Views/MainTabView.swift
+++ b/BanYaroGo/Views/MainTabView.swift
@@ -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 }
}
}
}
diff --git a/BanYaroGo/Views/RootView.swift b/BanYaroGo/Views/RootView.swift
index a58e589..f8d814b 100644
--- a/BanYaroGo/Views/RootView.swift
+++ b/BanYaroGo/Views/RootView.swift
@@ -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()
+ }
}
diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift
index 13d79ce..aefe066 100644
--- a/BanYaroGo/Views/TrackingView.swift
+++ b/BanYaroGo/Views/TrackingView.swift
@@ -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
diff --git a/BanYaroGoWidget.entitlements b/BanYaroGoWidget.entitlements
new file mode 100644
index 0000000..93071dd
--- /dev/null
+++ b/BanYaroGoWidget.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.app.banyaro.ios
+
+
+
diff --git a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
index 318e7f0..1126b63 100644
--- a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
+++ b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
@@ -5,5 +5,6 @@ import SwiftUI
struct BanYaroGoWidgetBundle: WidgetBundle {
var body: some Widget {
WalkLiveActivity()
+ BanYaroHomeWidget()
}
}
diff --git a/BanYaroGoWidget/BanYaroHomeWidget.swift b/BanYaroGoWidget/BanYaroHomeWidget.swift
new file mode 100644
index 0000000..8960237
--- /dev/null
+++ b/BanYaroGoWidget/BanYaroHomeWidget.swift
@@ -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) -> Void) {
+ let entry = BanYaroEntry(date: Date(), data: HomeWidgetStore.load())
+ // Die App pusht bei Updates reloadAllTimelines(); als Sicherheitsnetz
+ // stündlich neu laden.
+ let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
+ ?? Date().addingTimeInterval(3600)
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+}
+
+struct BanYaroHomeWidgetEntryView: View {
+ @Environment(\.widgetFamily) private var family
+ let entry: BanYaroEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Spacer()
+ Text(entry.data?.dogName ?? "Ban Yaro")
+ .font(family == .systemSmall ? .headline : .title3)
+ .bold()
+ .foregroundStyle(.white)
+ .shadow(radius: 3)
+ if family != .systemSmall {
+ if let appt = entry.data?.nextAppointment, !appt.isEmpty {
+ Label(appt, systemImage: "calendar")
+ .font(.caption)
+ .foregroundStyle(.white)
+ .shadow(radius: 3)
+ } else if let n = entry.data?.diaryCount, n > 0 {
+ Label("\(n) Tagebuch-Einträge", systemImage: "book")
+ .font(.caption)
+ .foregroundStyle(.white.opacity(0.9))
+ .shadow(radius: 3)
+ } else {
+ Text("Schön, dass du da bist 🐾")
+ .font(.caption)
+ .foregroundStyle(.white.opacity(0.9))
+ .shadow(radius: 3)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct BanYaroHomeWidget: Widget {
+ let kind = "BanYaroHomeWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: BanYaroProvider()) { entry in
+ BanYaroHomeWidgetEntryView(entry: entry)
+ .containerBackground(for: .widget) {
+ if let jpeg = entry.data?.photoJPEG, let ui = UIImage(data: jpeg) {
+ ZStack {
+ Image(uiImage: ui).resizable().scaledToFill()
+ LinearGradient(
+ colors: [.black.opacity(0.0), .black.opacity(0.55)],
+ startPoint: .center, endPoint: .bottom
+ )
+ }
+ } else {
+ Color.accentColor.opacity(0.25)
+ }
+ }
+ }
+ .configurationDisplayName("Ban Yaro")
+ .description("Tagesfoto deines Hundes und nächster Termin.")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+}
diff --git a/BanYaroGoWidget/HomeWidgetData.swift b/BanYaroGoWidget/HomeWidgetData.swift
new file mode 100644
index 0000000..9d32037
--- /dev/null
+++ b/BanYaroGoWidget/HomeWidgetData.swift
@@ -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)
+ }
+}