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