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:
parent
a2646a18ef
commit
d807db57a2
14 changed files with 307 additions and 6 deletions
|
|
@ -8,5 +8,9 @@
|
|||
<array/>
|
||||
<key>com.apple.developer.weatherkit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.banyaro.ios</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
33
BanYaroGo/Support/HomeWidgetData.swift
Normal file
33
BanYaroGo/Support/HomeWidgetData.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
BanYaroGo/Support/StartWalkIntent.swift
Normal file
32
BanYaroGo/Support/StartWalkIntent.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
33
BanYaroGo/Support/WalkLauncher.swift
Normal file
33
BanYaroGo/Support/WalkLauncher.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
BanYaroGoWidget.entitlements
Normal file
10
BanYaroGoWidget.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.banyaro.ios</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -5,5 +5,6 @@ import SwiftUI
|
|||
struct BanYaroGoWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
WalkLiveActivity()
|
||||
BanYaroHomeWidget()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
BanYaroGoWidget/BanYaroHomeWidget.swift
Normal file
88
BanYaroGoWidget/BanYaroHomeWidget.swift
Normal file
|
|
@ -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<BanYaroEntry>) -> 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])
|
||||
}
|
||||
}
|
||||
22
BanYaroGoWidget/HomeWidgetData.swift
Normal file
22
BanYaroGoWidget/HomeWidgetData.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue