From f7716f6a5a3133390b3cff065182cee9c50d9071 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 21 May 2026 17:11:18 +0200 Subject: [PATCH 1/3] add template widget communication helpers --- README.md | 55 +++- .../app/capgo/widgetkit/CapgoWidgetKit.java | 12 +- .../capgo/widgetkit/CapgoWidgetKitPlugin.java | 6 + .../CapgoTemplateWidgetBridge.swift | 21 ++ .../CapgoTemplateWidgetViews.swift | 254 ++++++++++++++++++ .../CapgoWidgetKitPlugin/CapgoWidgetKit.swift | 29 +- .../CapgoWidgetKitPlugin.swift | 9 +- .../TemplateLiveActivityManager.swift | 21 +- src/definitions.ts | 23 ++ src/web.ts | 4 + 10 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift diff --git a/README.md b/README.md index 5983e68..0df12ea 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ The plugin ships the native pieces a widget extension or Android widget can use: - `CapgoTemplateActivityAttributes` for the iOS Live Activity bridge - `CapgoTemplateActionIntent` for interactive iOS template buttons - `CapgoTemplateWidgetBridge` to load a stored SVG activity and resolve one surface into `svg + width/height + frameId + hotspots + metadata` +- `CapgoTemplateSurfaceView`, `CapgoTemplateWidgetSurface`, and `CapgoTemplateLatestWidgetSurface` to place a rendered SVG view under native hotspot buttons in SwiftUI - `CapgoNativeWidgetBridge` to load full-native widget sessions and exchange async messages without using SVG templates - `CapgoTemplateActionReceiver` and `CapgoTemplateWidgetBridge` for Android template widgets @@ -69,7 +70,18 @@ struct ExampleWidgetBundle: WidgetBundle { } ``` -See `example-app/widget-extension/ExampleWidgetBundle.swift` for a complete scaffold. The sample intentionally uses a placeholder card so you can plug in your own SVG renderer while keeping the same bridge and action intent wiring. +Use the SwiftUI surface helpers when the widget should be designed from JS but rendered by native widget code: + +```swift +CapgoTemplateLatestWidgetSurface { + layout in + MySvgRenderer(svg: layout.svg) +} placeholder: { + Text("No active widget") +} +``` + +The helper positions `CapgoTemplateActionIntent` buttons over the resolved SVG hotspots on iOS 17+. See `example-app/widget-extension/ExampleWidgetBundle.swift` for a complete scaffold. ## SVG Template Usage @@ -81,6 +93,7 @@ import { CapgoWidgetKit } from '@capgo/capacitor-widget-kit'; const { activity } = await CapgoWidgetKit.startTemplateActivity({ activityId: 'session-1', openUrl: 'widgetkitdemo://session/session-1', + startLiveActivity: false, state: { title: 'Chest Day', frame: 'summary', @@ -123,6 +136,8 @@ const { activity } = await CapgoWidgetKit.startTemplateActivity({ }, }); +await CapgoWidgetKit.reloadWidgets(); + await CapgoWidgetKit.performTemplateAction({ activityId: activity.activityId, actionId: 'toggle-rest', @@ -204,6 +219,7 @@ The workout helper is only used there as an example template factory. * [`listWidgetMessages(...)`](#listwidgetmessages) * [`acknowledgeWidgetMessages(...)`](#acknowledgewidgetmessages) * [`completeWidgetMessage(...)`](#completewidgetmessage) +* [`reloadWidgets(...)`](#reloadwidgets) * [`getPluginVersion()`](#getpluginversion) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) @@ -513,6 +529,21 @@ Complete or fail an async widget bridge message. -------------------- +### reloadWidgets(...) + +```typescript +reloadWidgets(options?: ReloadWidgetsOptions | undefined) => Promise +``` + +Ask native widgets to reload after external app state changes. + +| Param | Type | +| ------------- | --------------------------------------------------------------------- | +| **`options`** | ReloadWidgetsOptions | + +-------------------- + + ### getPluginVersion() ```typescript @@ -740,12 +771,13 @@ Persisted timer runtime state. Options for starting a generic SVG template activity. -| Prop | Type | Description | -| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| **`activityId`** | string | Optional explicit activity identifier. When omitted, the native runtime creates one. | -| **`definition`** | SvgTemplateDefinition | Generic SVG template definition. | -| **`state`** | SvgTemplateState | Initial JSON state exposed under `state.*`. | -| **`openUrl`** | string | Optional deep link used when the widget body is tapped. | +| Prop | Type | Description | +| ----------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`activityId`** | string | Optional explicit activity identifier. When omitted, the native runtime creates one. | +| **`definition`** | SvgTemplateDefinition | Generic SVG template definition. | +| **`state`** | SvgTemplateState | Initial JSON state exposed under `state.*`. | +| **`openUrl`** | string | Optional deep link used when the widget body is tapped. | +| **`startLiveActivity`** | boolean | Whether iOS should also start a native Live Activity. Defaults to `true`. Set to `false` when the same SVG template should only back a home-screen or lock-screen widget surface. | #### TemplateActivityResult @@ -1046,6 +1078,15 @@ Options for completing an async widget bridge message. | **`error`** | string | Optional error string. When set, the message status becomes `failed`. | +#### ReloadWidgetsOptions + +Options for forcing installed native widgets to reload their timeline. + +| Prop | Type | Description | +| ---------- | ------------------- | ---------------------------------------------------------------------------------------------- | +| **`kind`** | string | Optional native widget kind to reload on iOS. When omitted, every widget timeline is reloaded. | + + #### PluginVersionResult Result payload for plugin version queries. diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java index b429611..f4e4881 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java @@ -185,6 +185,11 @@ public JSONObject completeWidgetMessage(final String messageId, final JSONObject return message; } + public void reloadWidgets(final String kind) { + notifyStoreChanged(null); + notifyWidgetBridgeChanged(null, null); + } + public JSONObject getPluginVersion() throws JSONException { String versionName = "android"; try { @@ -199,9 +204,10 @@ public JSONObject getPluginVersion() throws JSONException { } private void notifyStoreChanged(final String activityId) { - final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_TEMPLATE_STORE_CHANGED) - .setPackage(context.getPackageName()) - .putExtra(CapgoWidgetKitConstants.EXTRA_ACTIVITY_ID, activityId); + final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_TEMPLATE_STORE_CHANGED).setPackage(context.getPackageName()); + if (activityId != null) { + intent.putExtra(CapgoWidgetKitConstants.EXTRA_ACTIVITY_ID, activityId); + } context.sendBroadcast(intent); } diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java index 1ee427e..3731588 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java @@ -347,6 +347,12 @@ public void completeWidgetMessage(final PluginCall call) { } } + @PluginMethod + public void reloadWidgets(final PluginCall call) { + implementation.reloadWidgets(call.getString("kind")); + call.resolve(); + } + @PluginMethod public void getPluginVersion(final PluginCall call) { try { diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift index 87dab87..725d507 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift @@ -102,6 +102,15 @@ public final class CapgoTemplateWidgetBridge { try TemplateActivityStore.make(bundle: bundle).listEnvelopes() } + public func latestActivity(status: String? = "active", bundle: Bundle = .main) throws -> StoredTemplateActivityEnvelope? { + try listActivities(bundle: bundle).first { envelope in + guard let status else { + return true + } + return envelope.status == status + } + } + public func resolveLayout( activityId: String, surface: CapgoTemplateSurface, @@ -143,6 +152,18 @@ public final class CapgoTemplateWidgetBridge { ) } + public func resolveLatestLayout( + surface: CapgoTemplateSurface, + status: String? = "active", + bundle: Bundle = .main, + now: Date = Date() + ) throws -> CapgoResolvedTemplateLayout? { + guard let envelope = try latestActivity(status: status, bundle: bundle) else { + return nil + } + return try resolveLayout(activityId: envelope.activityId, surface: surface, bundle: bundle, now: now) + } + private func parseHotspots(_ value: Any?) -> [CapgoTemplateResolvedHotspot] { guard let hotspots = value as? [[String: Any]] else { return [] diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift new file mode 100644 index 0000000..6f8ef93 --- /dev/null +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift @@ -0,0 +1,254 @@ +#if canImport(SwiftUI) +import Foundation +import SwiftUI +#if canImport(AppIntents) +import AppIntents +#endif + +@available(iOS 16.0, *) +public struct CapgoTemplateClearHotspotLabel: View { + public init() {} + + public var body: some View { + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateHotspotButton: View { + public let activityId: String + public let hotspot: CapgoTemplateResolvedHotspot + private let label: () -> Label + + public init( + activityId: String, + hotspot: CapgoTemplateResolvedHotspot, + @ViewBuilder label: @escaping () -> Label + ) { + self.activityId = activityId + self.hotspot = hotspot + self.label = label + } + + public var body: some View { + Group { + #if canImport(AppIntents) + if #available(iOS 17.0, *) { + Button( + intent: CapgoTemplateActionIntent( + activityId: activityId, + actionId: hotspot.actionId, + sourceId: hotspot.id, + payloadJSON: hotspot.payloadJSON + ) + ) { + label() + } + .buttonStyle(.plain) + } else { + fallbackLabel + } + #else + fallbackLabel + #endif + } + .accessibilityLabel(Text(hotspot.label ?? hotspot.actionId)) + } + + private var fallbackLabel: some View { + label() + .allowsHitTesting(false) + } +} + +@available(iOS 16.0, *) +public extension CapgoTemplateHotspotButton where Label == CapgoTemplateClearHotspotLabel { + init(activityId: String, hotspot: CapgoTemplateResolvedHotspot) { + self.init(activityId: activityId, hotspot: hotspot) { + CapgoTemplateClearHotspotLabel() + } + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateSurfaceView: View { + public let layout: CapgoResolvedTemplateLayout? + public let showsHotspots: Bool + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + layout: CapgoResolvedTemplateLayout?, + showsHotspots: Bool = true, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.layout = layout + self.showsHotspots = showsHotspots + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + Group { + if let layout { + surface(layout) + } else { + placeholder() + } + } + } + + private func surface(_ layout: CapgoResolvedTemplateLayout) -> some View { + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + svgContent(layout) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if showsHotspots { + ForEach(layout.hotspots, id: \.id) { hotspot in + CapgoTemplateHotspotButton(activityId: layout.activityId, hotspot: hotspot) + .frame( + width: hotspotWidth(hotspot, in: proxy.size, layout: layout), + height: hotspotHeight(hotspot, in: proxy.size, layout: layout) + ) + .position( + x: hotspotCenterX(hotspot, in: proxy.size, layout: layout), + y: hotspotCenterY(hotspot, in: proxy.size, layout: layout) + ) + } + } + } + } + .aspectRatio(CGFloat(layout.width / max(layout.height, 1)), contentMode: .fit) + } + + private func hotspotWidth( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + max(1, size.width * CGFloat(hotspot.width / max(layout.width, 1))) + } + + private func hotspotHeight( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + max(1, size.height * CGFloat(hotspot.height / max(layout.height, 1))) + } + + private func hotspotCenterX( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + size.width * CGFloat((hotspot.x + hotspot.width / 2) / max(layout.width, 1)) + } + + private func hotspotCenterY( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + size.height * CGFloat((hotspot.y + hotspot.height / 2) / max(layout.height, 1)) + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateWidgetSurface: View { + private let activityId: String + private let surface: CapgoTemplateSurface + private let bundle: Bundle + private let showsHotspots: Bool + private let now: () -> Date + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + activityId: String, + surface: CapgoTemplateSurface = .lockScreen, + bundle: Bundle = .main, + showsHotspots: Bool = true, + now: @escaping () -> Date = { Date() }, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.activityId = activityId + self.surface = surface + self.bundle = bundle + self.showsHotspots = showsHotspots + self.now = now + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + CapgoTemplateSurfaceView( + layout: resolvedLayout, + showsHotspots: showsHotspots, + svgContent: svgContent, + placeholder: placeholder + ) + } + + private var resolvedLayout: CapgoResolvedTemplateLayout? { + try? CapgoTemplateWidgetBridge().resolveLayout( + activityId: activityId, + surface: surface, + bundle: bundle, + now: now() + ) + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateLatestWidgetSurface: View { + private let surface: CapgoTemplateSurface + private let status: String? + private let bundle: Bundle + private let showsHotspots: Bool + private let now: () -> Date + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + surface: CapgoTemplateSurface = .lockScreen, + status: String? = "active", + bundle: Bundle = .main, + showsHotspots: Bool = true, + now: @escaping () -> Date = { Date() }, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.surface = surface + self.status = status + self.bundle = bundle + self.showsHotspots = showsHotspots + self.now = now + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + CapgoTemplateSurfaceView( + layout: resolvedLayout, + showsHotspots: showsHotspots, + svgContent: svgContent, + placeholder: placeholder + ) + } + + private var resolvedLayout: CapgoResolvedTemplateLayout? { + try? CapgoTemplateWidgetBridge().resolveLatestLayout( + surface: surface, + status: status, + bundle: bundle, + now: now() + ) + } +} +#endif diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift index bc9e32a..35cc0a2 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif enum CapgoWidgetKitBridgeError: LocalizedError { case missingObject(String) @@ -30,13 +33,15 @@ public final class CapgoWidgetKit { activityId: String?, definitionObject: [String: Any], stateObject: [String: Any], - openUrl: String? + openUrl: String?, + startLiveActivity: Bool ) async throws -> [String: Any] { let envelope = try await TemplateLiveActivityManager.shared.start( activityId: activityId, definitionObject: definitionObject, stateObject: stateObject, - openUrl: openUrl + openUrl: openUrl, + startLiveActivity: startLiveActivity ) return ["activity": try TemplateRuntime.serializeActivity(envelope)] } @@ -120,6 +125,7 @@ public final class CapgoWidgetKit { stateObject: stateObject ?? [:], metadataObject: metadataObject ) + reloadWidgets(kind: nil) return ["session": try CapgoNativeWidgetBridge.serializeSession(session)] } @@ -138,11 +144,13 @@ public final class CapgoWidgetKit { guard let session else { return ["session": NSNull()] } + reloadWidgets(kind: nil) return ["session": try CapgoNativeWidgetBridge.serializeSession(session)] } public func stopWidgetSession(widgetId: String, stateObject: [String: Any]?) throws { try CapgoNativeWidgetBridge().stopSession(widgetId: widgetId, stateObject: stateObject) + reloadWidgets(kind: nil) } public func getWidgetSession(widgetId: String) throws -> [String: Any] { @@ -172,6 +180,7 @@ public final class CapgoWidgetKit { payloadObject: payloadObject, expectsResponse: expectsResponse ) + reloadWidgets(kind: nil) return ["message": try CapgoNativeWidgetBridge.serializeMessage(message)] } @@ -196,6 +205,7 @@ public final class CapgoWidgetKit { widgetId: widgetId, direction: direction.flatMap(CapgoWidgetMessageDirection.init(rawValue:)) ) + reloadWidgets(kind: nil) } public func completeWidgetMessage(messageId: String, responseObject: [String: Any]?, error: String?) throws -> [String: Any] { @@ -207,9 +217,24 @@ public final class CapgoWidgetKit { guard let message else { return ["message": NSNull()] } + reloadWidgets(kind: nil) return ["message": try CapgoNativeWidgetBridge.serializeMessage(message)] } + public func reloadWidgets(kind: String?) { + #if canImport(WidgetKit) + if #available(iOS 14.0, *) { + if let kind, !kind.isEmpty { + WidgetCenter.shared.reloadTimelines(ofKind: kind) + } else { + WidgetCenter.shared.reloadAllTimelines() + } + } + #else + _ = kind + #endif + } + public func getPluginVersion() -> [String: Any] { [ "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ios" diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift index bd75aef..65732c1 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift @@ -24,6 +24,7 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "listWidgetMessages", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "acknowledgeWidgetMessages", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "completeWidgetMessage", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "reloadWidgets", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise) ] @@ -50,7 +51,8 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { activityId: call.getString("activityId"), definitionObject: definition, stateObject: state, - openUrl: call.getString("openUrl") + openUrl: call.getString("openUrl"), + startLiveActivity: call.getBool("startLiveActivity") ?? true ) call.resolve(payload) } catch { @@ -316,6 +318,11 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { } } + @objc func reloadWidgets(_ call: CAPPluginCall) { + implementation.reloadWidgets(kind: call.getString("kind")) + call.resolve() + } + @objc func getPluginVersion(_ call: CAPPluginCall) { call.resolve(implementation.getPluginVersion()) } diff --git a/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift b/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift index 69ae32d..0aae210 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift @@ -2,6 +2,9 @@ import ActivityKit #endif import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif public struct TemplateActivityInfo: Hashable, Sendable { public let activityId: String @@ -55,7 +58,8 @@ public final class TemplateLiveActivityManager { activityId requestedActivityId: String?, definitionObject: [String: Any], stateObject: [String: Any], - openUrl: String? + openUrl: String?, + startLiveActivity: Bool = true ) async throws -> StoredTemplateActivityEnvelope { let nowMs = Int64(Date().timeIntervalSince1970 * 1000) let activityId: String @@ -74,7 +78,7 @@ public final class TemplateLiveActivityManager { let store = try TemplateActivityStore.make() #if canImport(ActivityKit) - if #available(iOS 16.2, *) { + if #available(iOS 16.2, *), startLiveActivity { let activity = try Activity.request( attributes: CapgoTemplateActivityAttributes( activityId: activityId, @@ -91,6 +95,7 @@ public final class TemplateLiveActivityManager { let envelope = try record.toEnvelope() try store.saveEnvelope(envelope) + reloadWidgetTimelines() return envelope } @@ -123,6 +128,7 @@ public final class TemplateLiveActivityManager { let envelope = try record.toEnvelope() try store.saveEnvelope(envelope) try await updateNativeActivity(for: envelope) + reloadWidgetTimelines() return envelope } @@ -150,6 +156,7 @@ public final class TemplateLiveActivityManager { #endif store.removeNativeActivityId(for: activityId) + reloadWidgetTimelines() } public func performAction( @@ -177,6 +184,7 @@ public final class TemplateLiveActivityManager { try store.saveEnvelope(envelope) try store.appendEvent(event) try await updateNativeActivity(for: envelope) + reloadWidgetTimelines() return (envelope, event) } @@ -209,6 +217,15 @@ public final class TemplateLiveActivityManager { activityId: activityId, acknowledgedAtMs: Int64(Date().timeIntervalSince1970 * 1000) ) + reloadWidgetTimelines() + } + + private func reloadWidgetTimelines() { + #if canImport(WidgetKit) + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + #endif } #if canImport(ActivityKit) diff --git a/src/definitions.ts b/src/definitions.ts index dfb062f..9d96e41 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -703,6 +703,14 @@ export interface StartTemplateActivityOptions { * Optional deep link used when the widget body is tapped. */ openUrl?: string; + + /** + * Whether iOS should also start a native Live Activity. + * + * Defaults to `true`. Set to `false` when the same SVG template should only back + * a home-screen or lock-screen widget surface. + */ + startLiveActivity?: boolean; } /** @@ -1217,6 +1225,16 @@ export interface CompleteWidgetMessageOptions { error?: string; } +/** + * Options for forcing installed native widgets to reload their timeline. + */ +export interface ReloadWidgetsOptions { + /** + * Optional native widget kind to reload on iOS. When omitted, every widget timeline is reloaded. + */ + kind?: string; +} + /** * Capacitor bridge for an iOS-first WidgetKit / Live Activities plugin. * @@ -1323,6 +1341,11 @@ export interface CapgoWidgetKitPlugin { */ completeWidgetMessage(options: CompleteWidgetMessageOptions): Promise; + /** + * Ask native widgets to reload after external app state changes. + */ + reloadWidgets(options?: ReloadWidgetsOptions): Promise; + /** * Return the platform implementation version marker. */ diff --git a/src/web.ts b/src/web.ts index 26d891b..8d629d1 100644 --- a/src/web.ts +++ b/src/web.ts @@ -413,6 +413,10 @@ export class CapgoWidgetKitWeb extends WebPlugin implements CapgoWidgetKitPlugin return { message: cloneJson(nextMessage) }; } + async reloadWidgets(): Promise { + return; + } + async getPluginVersion(): Promise { return { version: 'web-preview', From 6d12ec16a2a9655f1b23a15cced35f04ce3f0f4e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 21 May 2026 17:42:07 +0200 Subject: [PATCH 2/3] address widget review feedback --- README.md | 2 - .../app/capgo/widgetkit/CapgoWidgetKit.java | 18 +++++- .../widgetkit/CapgoWidgetKitConstants.java | 1 + .../CapgoTemplateWidgetBridge.swift | 14 +++-- .../CapgoTemplateWidgetViews.swift | 58 +++++++++++++++---- src/web.ts | 4 +- 6 files changed, 75 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0df12ea..401051c 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,6 @@ const { activity } = await CapgoWidgetKit.startTemplateActivity({ }, }); -await CapgoWidgetKit.reloadWidgets(); - await CapgoWidgetKit.performTemplateAction({ activityId: activity.activityId, actionId: 'toggle-rest', diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java index f4e4881..de45e1d 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java @@ -186,8 +186,8 @@ public JSONObject completeWidgetMessage(final String messageId, final JSONObject } public void reloadWidgets(final String kind) { - notifyStoreChanged(null); - notifyWidgetBridgeChanged(null, null); + notifyStoreChanged(null, kind); + notifyWidgetBridgeChanged(null, null, kind); } public JSONObject getPluginVersion() throws JSONException { @@ -204,14 +204,25 @@ public JSONObject getPluginVersion() throws JSONException { } private void notifyStoreChanged(final String activityId) { + notifyStoreChanged(activityId, null); + } + + private void notifyStoreChanged(final String activityId, final String kind) { final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_TEMPLATE_STORE_CHANGED).setPackage(context.getPackageName()); if (activityId != null) { intent.putExtra(CapgoWidgetKitConstants.EXTRA_ACTIVITY_ID, activityId); } + if (kind != null) { + intent.putExtra(CapgoWidgetKitConstants.EXTRA_WIDGET_KIND, kind); + } context.sendBroadcast(intent); } private void notifyWidgetBridgeChanged(final String widgetId, final String messageId) { + notifyWidgetBridgeChanged(widgetId, messageId, null); + } + + private void notifyWidgetBridgeChanged(final String widgetId, final String messageId, final String kind) { final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_NATIVE_WIDGET_BRIDGE_CHANGED).setPackage(context.getPackageName()); if (widgetId != null) { intent.putExtra(CapgoWidgetKitConstants.EXTRA_WIDGET_ID, widgetId); @@ -219,6 +230,9 @@ private void notifyWidgetBridgeChanged(final String widgetId, final String messa if (messageId != null) { intent.putExtra(CapgoWidgetKitConstants.EXTRA_MESSAGE_ID, messageId); } + if (kind != null) { + intent.putExtra(CapgoWidgetKitConstants.EXTRA_WIDGET_KIND, kind); + } context.sendBroadcast(intent); } } diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java index 648f439..6340119 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java @@ -10,6 +10,7 @@ public final class CapgoWidgetKitConstants { public static final String EXTRA_ACTION_ID = "actionId"; public static final String EXTRA_SOURCE_ID = "sourceId"; public static final String EXTRA_PAYLOAD_JSON = "payloadJson"; + public static final String EXTRA_WIDGET_KIND = "widgetKind"; public static final String EXTRA_WIDGET_ID = "widgetId"; public static final String EXTRA_MESSAGE_ID = "messageId"; public static final String ACTIVITY_IDS_KEY = "activityIds"; diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift index 725d507..f835364 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift @@ -103,12 +103,16 @@ public final class CapgoTemplateWidgetBridge { } public func latestActivity(status: String? = "active", bundle: Bundle = .main) throws -> StoredTemplateActivityEnvelope? { - try listActivities(bundle: bundle).first { envelope in - guard let status else { - return true + try listActivities(bundle: bundle) + .filter { envelope in + guard let status else { + return true + } + return envelope.status == status + } + .max { left, right in + left.updatedAtMs < right.updatedAtMs } - return envelope.status == status - } } public func resolveLayout( diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift index 6f8ef93..297b01b 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift @@ -1,5 +1,6 @@ #if canImport(SwiftUI) import Foundation +import OSLog import SwiftUI #if canImport(AppIntents) import AppIntents @@ -196,12 +197,17 @@ public struct CapgoTemplateWidgetSurface: V } private var resolvedLayout: CapgoResolvedTemplateLayout? { - try? CapgoTemplateWidgetBridge().resolveLayout( - activityId: activityId, - surface: surface, - bundle: bundle, - now: now() - ) + do { + return try CapgoTemplateWidgetBridge().resolveLayout( + activityId: activityId, + surface: surface, + bundle: bundle, + now: now() + ) + } catch { + logTemplateWidgetResolutionFailure(error, surface: surface, bundle: bundle, activityId: activityId) + return nil + } } } @@ -243,12 +249,40 @@ public struct CapgoTemplateLatestWidgetSurface { + async reloadWidgets(options?: ReloadWidgetsOptions): Promise { + void options; return; } From 91aa2be8847badfb2cceeb4a2926ca1e089c3aea Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 21 May 2026 22:35:15 +0200 Subject: [PATCH 3/3] add home screen template widgets --- README.md | 103 +++++++++++---- .../capgo/widgetkit/CapgoWidgetKitPlugin.java | 9 ++ .../app/capgo/widgetkit/TemplateRuntime.java | 12 +- .../TemplateSampleWidgetProvider.java | 4 +- .../ExampleWidgetBundle.swift | 19 +++ example-app/src/main.js | 6 +- .../ExampleWidgetBundle.swift | 19 +++ .../CapgoTemplateHomeWidget.swift | 117 ++++++++++++++++++ .../CapgoTemplateWidgetBridge.swift | 14 ++- .../CapgoWidgetKitPlugin.swift | 11 +- package.json | 3 +- src/definitions.ts | 50 +++++++- src/helpers/workout.ts | 28 +++++ src/runtime.ts | 7 +- src/web.ts | 7 +- 15 files changed, 367 insertions(+), 42 deletions(-) create mode 100644 ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift diff --git a/README.md b/README.md index 401051c..cd00b37 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Missing a feature? We’ll build the plugin for you 💪

-Create WidgetKit, ActivityKit, and Android widget experiences from Capacitor without forcing one rendering model. +Create Home Screen WidgetKit, ActivityKit, and Android widget experiences from Capacitor without forcing one rendering model. ## Demo @@ -14,7 +14,7 @@ Create WidgetKit, ActivityKit, and Android widget experiences from Capacitor wit The plugin has two implementation paths: -- SVG template activities: store SVG layouts, optional named frames, hotspots, declarative state patches, pause/resume timers, and interaction events. Use this when your widget can be driven by resolved SVG output. +- SVG template widgets: store Home Screen, Lock Screen, Dynamic Island, and Android layouts with optional named frames, hotspots, declarative state patches, pause/resume timers, and interaction events. Use this when your widget can be driven by resolved SVG output. - Full-native widget sessions: store shared JSON state for native widget code and queue app-to-widget or widget-to-app messages. Use this when you want to render the widget fully in Swift/Kotlin/Java but still need Capacitor to start, stop, sync, or process async work. The included workout flow is only an example helper built on top of the generic SVG abstraction. @@ -29,7 +29,8 @@ bunx cap sync android ## iOS Requirements -- iOS 17+ is recommended for interactive Live Activity buttons. +- Add a Widget Extension target for Home Screen / SpringBoard widgets. +- iOS 17+ is recommended for interactive Home Screen widget and Live Activity buttons. - Add `NSSupportsLiveActivities` to the app `Info.plist` when using ActivityKit. - Add the same App Group to the app target and the widget extension target. - Set `CapgoWidgetKitAppGroup` in both `Info.plist` files to the shared App Group identifier. @@ -47,6 +48,7 @@ The plugin ships the native pieces a widget extension or Android widget can use: - `CapgoTemplateActivityAttributes` for the iOS Live Activity bridge - `CapgoTemplateActionIntent` for interactive iOS template buttons +- `CapgoTemplateWidgetTimelineProvider` and `CapgoTemplateHomeWidgetView` for real iOS Home Screen / SpringBoard widgets backed by WidgetKit timelines - `CapgoTemplateWidgetBridge` to load a stored SVG activity and resolve one surface into `svg + width/height + frameId + hotspots + metadata` - `CapgoTemplateSurfaceView`, `CapgoTemplateWidgetSurface`, and `CapgoTemplateLatestWidgetSurface` to place a rendered SVG view under native hotspot buttons in SwiftUI - `CapgoNativeWidgetBridge` to load full-native widget sessions and exchange async messages without using SVG templates @@ -63,14 +65,33 @@ import CapgoWidgetKitPlugin @main struct ExampleWidgetBundle: WidgetBundle { var body: some Widget { + ExampleTemplateHomeWidget() + if #available(iOS 16.2, *) { ExampleTemplateLiveActivityWidget() } } } + +struct ExampleTemplateHomeWidget: Widget { + private let kind = "ExampleTemplateHomeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CapgoTemplateWidgetTimelineProvider()) { entry in + CapgoTemplateHomeWidgetView(entry: entry) { layout in + MySvgRenderer(svg: layout.svg) + } placeholder: { + Text("No active widget") + } + } + .configurationDisplayName("Capgo Template") + .description("Home Screen widget rendered from the shared Capgo SVG template store.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} ``` -Use the SwiftUI surface helpers when the widget should be designed from JS but rendered by native widget code: +Use the SwiftUI surface helpers when a custom WidgetKit view should be designed from JS but rendered by native widget code: ```swift CapgoTemplateLatestWidgetSurface { @@ -81,19 +102,18 @@ CapgoTemplateLatestWidgetSurface { } ``` -The helper positions `CapgoTemplateActionIntent` buttons over the resolved SVG hotspots on iOS 17+. See `example-app/widget-extension/ExampleWidgetBundle.swift` for a complete scaffold. +The helper positions `CapgoTemplateActionIntent` buttons over the resolved SVG hotspots on iOS 17+. See `example-app/widget-extension/ExampleWidgetBundle.swift` for a complete Home Screen widget and Live Activity scaffold. ## SVG Template Usage -This mode is for widgets that can render resolved SVG. Hotspot actions can switch frames, mutate state, pause/play timers, and emit events for the app to process later. +This mode is for Home Screen, Lock Screen, Dynamic Island, and Android widgets that can render resolved SVG. Hotspot actions can switch frames, mutate state, pause/play timers, and emit events for the app to process later. ```ts import { CapgoWidgetKit } from '@capgo/capacitor-widget-kit'; -const { activity } = await CapgoWidgetKit.startTemplateActivity({ +const { activity } = await CapgoWidgetKit.startTemplateWidget({ activityId: 'session-1', openUrl: 'widgetkitdemo://session/session-1', - startLiveActivity: false, state: { title: 'Chest Day', frame: 'summary', @@ -106,7 +126,7 @@ const { activity } = await CapgoWidgetKit.startTemplateActivity({ { id: 'next-frame', eventName: 'widget.frame.changed', - frameMutations: [{ op: 'next', path: 'frame', surface: 'lockScreen' }], + frameMutations: [{ op: 'next', path: 'frame', surface: 'homeScreen' }], }, { id: 'toggle-rest', @@ -115,7 +135,7 @@ const { activity } = await CapgoWidgetKit.startTemplateActivity({ }, ], layouts: { - lockScreen: { + homeScreen: { width: 100, height: 40, frameIdPath: 'state.frame', @@ -186,12 +206,12 @@ await CapgoWidgetKit.stopWidgetSession({ widgetId: session.widgetId }); The `example-app/` folder is a lightweight Vite demo for the generic template flow. It runs in the browser using the preview store and demonstrates: -- starting one SVG template activity -- resolving the lock-screen surface +- starting one SVG template widget for the Home Screen surface +- resolving the Home Screen surface - running an action from the app and from a hotspot overlay -- reading the stored activity back +- reading the stored widget template back - reading and acknowledging the event log -- ending the activity +- ending the stored template The workout helper is only used there as an example template factory. @@ -201,6 +221,7 @@ The workout helper is only used there as an example template factory. * [`areActivitiesSupported()`](#areactivitiessupported) * [`startTemplateActivity(...)`](#starttemplateactivity) +* [`startTemplateWidget(...)`](#starttemplatewidget) * [`updateTemplateActivity(...)`](#updatetemplateactivity) * [`endTemplateActivity(...)`](#endtemplateactivity) * [`performTemplateAction(...)`](#performtemplateaction) @@ -229,14 +250,15 @@ The workout helper is only used there as an example template factory. Capacitor bridge for an iOS-first WidgetKit / Live Activities plugin. -The core abstraction is a generic SVG template activity: +The core abstraction is a generic SVG template record: - raw SVG templates with binding placeholders - declarative action patches - timer bindings exposed to the template scope - event logging so the host app can process button results later The plugin owns shared persistence, declarative action execution, and event retrieval. -The host widget extension keeps full freedom over actual WidgetKit rendering. +The host widget extension keeps full freedom over actual WidgetKit rendering, including +Home Screen / SpringBoard widgets backed by WidgetKit timelines. Full-native widgets can use widget sessions for synchronous shared state and widget messages for asynchronous app/widget jobs without adopting the SVG template renderer. @@ -271,6 +293,23 @@ Persist a generic SVG template activity and start the matching native Live Activ -------------------- +### startTemplateWidget(...) + +```typescript +startTemplateWidget(options: StartTemplateWidgetOptions) => Promise +``` + +Persist a generic SVG template for Home Screen / SpringBoard widgets without starting a Live Activity. + +| Param | Type | +| ------------- | --------------------------------------------------------------------------------- | +| **`options`** | StartTemplateWidgetOptions | + +**Returns:** Promise<StartTemplateActivityResult> + +-------------------- + + ### updateTemplateActivity(...) ```typescript @@ -611,13 +650,14 @@ Generic SVG template definition stored by the plugin. Bundle of optional WidgetKit surface layouts. -| Prop | Type | Description | -| ---------------------------------- | --------------------------------------------------------------- | ------------------------------------------------ | -| **`lockScreen`** | SvgTemplateLayout | Primary lock-screen / banner layout. | -| **`dynamicIslandExpanded`** | SvgTemplateLayout | Optional expanded Dynamic Island layout. | -| **`dynamicIslandCompactLeading`** | SvgTemplateLayout | Optional compact leading Dynamic Island layout. | -| **`dynamicIslandCompactTrailing`** | SvgTemplateLayout | Optional compact trailing Dynamic Island layout. | -| **`dynamicIslandMinimal`** | SvgTemplateLayout | Optional minimal Dynamic Island layout. | +| Prop | Type | Description | +| ---------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **`homeScreen`** | SvgTemplateLayout | Optional Home Screen / SpringBoard widget layout. When omitted, native Home Screen widgets may fall back to `lockScreen`. | +| **`lockScreen`** | SvgTemplateLayout | Optional lock-screen / Live Activity banner layout. | +| **`dynamicIslandExpanded`** | SvgTemplateLayout | Optional expanded Dynamic Island layout. | +| **`dynamicIslandCompactLeading`** | SvgTemplateLayout | Optional compact leading Dynamic Island layout. | +| **`dynamicIslandCompactTrailing`** | SvgTemplateLayout | Optional compact trailing Dynamic Island layout. | +| **`dynamicIslandMinimal`** | SvgTemplateLayout | Optional minimal Dynamic Island layout. | #### SvgTemplateLayoutWithSvg @@ -778,6 +818,21 @@ Options for starting a generic SVG template activity. | **`startLiveActivity`** | boolean | Whether iOS should also start a native Live Activity. Defaults to `true`. Set to `false` when the same SVG template should only back a home-screen or lock-screen widget surface. | +#### StartTemplateWidgetOptions + +Options for starting or replacing a Home Screen / SpringBoard widget template. + +This persists the same SVG template record as `startTemplateActivity`, but native iOS +implementations do not start an ActivityKit Live Activity. + +| Prop | Type | Description | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| **`activityId`** | string | Optional explicit activity identifier. When omitted, the native runtime creates one. | +| **`definition`** | SvgTemplateDefinition | Generic SVG template definition. | +| **`state`** | SvgTemplateState | Initial JSON state exposed under `state.*`. | +| **`openUrl`** | string | Optional deep link used when the widget body is tapped. | + + #### TemplateActivityResult Result when reading or updating a single activity. @@ -1129,7 +1184,7 @@ JSON-safe array used as activity state. Named WidgetKit surface for one SVG layout variant. -'lockScreen' | 'dynamicIslandExpanded' | 'dynamicIslandCompactLeading' | 'dynamicIslandCompactTrailing' | 'dynamicIslandMinimal' +'homeScreen' | 'lockScreen' | 'dynamicIslandExpanded' | 'dynamicIslandCompactLeading' | 'dynamicIslandCompactTrailing' | 'dynamicIslandMinimal' #### SvgTemplateState diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java index 3731588..c6a7421 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java @@ -32,6 +32,15 @@ public void areActivitiesSupported(final PluginCall call) { @PluginMethod public void startTemplateActivity(final PluginCall call) { + startTemplateRecord(call); + } + + @PluginMethod + public void startTemplateWidget(final PluginCall call) { + startTemplateRecord(call); + } + + private void startTemplateRecord(final PluginCall call) { final JSObject definition = call.getObject("definition"); final JSObject state = call.getObject("state"); diff --git a/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java b/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java index 723c39d..f477f40 100644 --- a/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java +++ b/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java @@ -167,7 +167,7 @@ static JSONArray acknowledgeEvents(final JSONArray events, final String activity static JSONObject resolveSurface(final JSONObject record, final String surface, final long nowMs) throws JSONException { final JSONObject layouts = record.getJSONObject("definition").getJSONObject("layouts"); - final JSONObject layout = layouts.optJSONObject(surface); + final JSONObject layout = layoutForSurface(layouts, surface); if (layout == null) { return null; } @@ -431,7 +431,7 @@ private static JSONArray frameIdsForMutation(final JSONObject activity, final JS return new JSONArray(); } - final JSONObject layout = activity.getJSONObject("definition").getJSONObject("layouts").optJSONObject(surface); + final JSONObject layout = layoutForSurface(activity.getJSONObject("definition").getJSONObject("layouts"), surface); final JSONArray frames = layout != null ? TemplateJsonUtils.arrayOrEmpty(layout, "frames") : new JSONArray(); final JSONArray frameIds = new JSONArray(); for (int index = 0; index < frames.length(); index += 1) { @@ -443,6 +443,14 @@ private static JSONArray frameIdsForMutation(final JSONObject activity, final JS return frameIds; } + private static JSONObject layoutForSurface(final JSONObject layouts, final String surface) { + final JSONObject layout = layouts.optJSONObject(surface); + if (layout != null) { + return layout; + } + return "homeScreen".equals(surface) ? layouts.optJSONObject("lockScreen") : null; + } + private static int indexOfString(final JSONArray values, final String target) { for (int index = 0; index < values.length(); index += 1) { if (target.equals(values.optString(index))) { diff --git a/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java b/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java index 8417a13..63d9bcb 100644 --- a/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java +++ b/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java @@ -18,7 +18,7 @@ public class TemplateSampleWidgetProvider extends AppWidgetProvider { - private static final String SURFACE_LOCK_SCREEN = "lockScreen"; + private static final String SURFACE_HOME_SCREEN = "homeScreen"; private static final int PREVIEW_WIDTH_PX = 720; private static final int PREVIEW_HEIGHT_PX = 360; @@ -65,7 +65,7 @@ private RemoteViews buildViews(final Context context) { return views; } - final JSONObject layout = bridge.resolveLayout(activity.optString("activityId"), SURFACE_LOCK_SCREEN); + final JSONObject layout = bridge.resolveLayout(activity.optString("activityId"), SURFACE_HOME_SCREEN); if (layout == null) { renderEmptyState(views); return views; diff --git a/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift b/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift index 142a2a0..17f32b1 100644 --- a/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift +++ b/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift @@ -6,12 +6,31 @@ import CapgoWidgetKitPlugin @main struct ExampleWidgetBundle: WidgetBundle { var body: some Widget { + ExampleTemplateHomeWidget() + if #available(iOS 16.2, *) { ExampleTemplateLiveActivityWidget() } } } +struct ExampleTemplateHomeWidget: Widget { + private let kind = "ExampleTemplateHomeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CapgoTemplateWidgetTimelineProvider()) { entry in + CapgoTemplateHomeWidgetView(entry: entry) { layout in + ExampleTemplateSurfaceCard(layout: layout) + } placeholder: { + ExampleTemplateSurfaceCard(layout: nil) + } + } + .configurationDisplayName("Capgo Template") + .description("Home Screen widget rendered from the shared Capgo SVG template store.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + @available(iOS 16.2, *) struct ExampleTemplateLiveActivityWidget: Widget { private let bridge = CapgoTemplateWidgetBridge() diff --git a/example-app/src/main.js b/example-app/src/main.js index 0069ae1..4d60093 100644 --- a/example-app/src/main.js +++ b/example-app/src/main.js @@ -281,11 +281,11 @@ const renderPreview = () => { return; } - const resolvedLayout = resolveTemplateSurface(currentActivity, 'lockScreen'); + const resolvedLayout = resolveTemplateSurface(currentActivity, 'homeScreen'); if (!resolvedLayout) { preview.innerHTML = `
-

The lock screen surface is missing from this activity.

+

The home screen surface is missing from this activity.

`; updateActionState(); @@ -341,7 +341,7 @@ const checkSupport = async () => { const startTemplate = async () => { try { - const result = await CapgoWidgetKit.startTemplateActivity(createWorkoutTemplateActivity(sampleSession())); + const result = await CapgoWidgetKit.startTemplateWidget(createWorkoutTemplateActivity(sampleSession())); currentActivity = result.activity; currentEvents = []; setActivityBadge(currentActivity); diff --git a/example-app/widget-extension/ExampleWidgetBundle.swift b/example-app/widget-extension/ExampleWidgetBundle.swift index 142a2a0..17f32b1 100644 --- a/example-app/widget-extension/ExampleWidgetBundle.swift +++ b/example-app/widget-extension/ExampleWidgetBundle.swift @@ -6,12 +6,31 @@ import CapgoWidgetKitPlugin @main struct ExampleWidgetBundle: WidgetBundle { var body: some Widget { + ExampleTemplateHomeWidget() + if #available(iOS 16.2, *) { ExampleTemplateLiveActivityWidget() } } } +struct ExampleTemplateHomeWidget: Widget { + private let kind = "ExampleTemplateHomeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CapgoTemplateWidgetTimelineProvider()) { entry in + CapgoTemplateHomeWidgetView(entry: entry) { layout in + ExampleTemplateSurfaceCard(layout: layout) + } placeholder: { + ExampleTemplateSurfaceCard(layout: nil) + } + } + .configurationDisplayName("Capgo Template") + .description("Home Screen widget rendered from the shared Capgo SVG template store.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + @available(iOS 16.2, *) struct ExampleTemplateLiveActivityWidget: Widget { private let bridge = CapgoTemplateWidgetBridge() diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift new file mode 100644 index 0000000..effb550 --- /dev/null +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift @@ -0,0 +1,117 @@ +#if canImport(SwiftUI) && canImport(WidgetKit) +import Foundation +import OSLog +import SwiftUI +import WidgetKit + +@available(iOS 16.0, *) +public struct CapgoTemplateWidgetEntry: TimelineEntry, Sendable { + public let date: Date + public let layout: CapgoResolvedTemplateLayout? + + public init(date: Date, layout: CapgoResolvedTemplateLayout?) { + self.date = date + self.layout = layout + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateWidgetTimelineProvider: TimelineProvider { + public typealias Entry = CapgoTemplateWidgetEntry + + private let surface: CapgoTemplateSurface + private let status: String? + private let bundle: Bundle + private let refreshInterval: TimeInterval + private let bridge: CapgoTemplateWidgetBridge + + public init( + surface: CapgoTemplateSurface = .homeScreen, + status: String? = "active", + bundle: Bundle = .main, + refreshInterval: TimeInterval = 15 * 60, + bridge: CapgoTemplateWidgetBridge = CapgoTemplateWidgetBridge() + ) { + self.surface = surface + self.status = status + self.bundle = bundle + self.refreshInterval = refreshInterval + self.bridge = bridge + } + + public func placeholder(in context: Context) -> CapgoTemplateWidgetEntry { + CapgoTemplateWidgetEntry(date: Date(), layout: nil) + } + + public func getSnapshot(in context: Context, completion: @escaping (CapgoTemplateWidgetEntry) -> Void) { + completion(entry(date: Date())) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let date = Date() + let entry = entry(date: date) + let nextReload = date.addingTimeInterval(max(refreshInterval, 60)) + completion(Timeline(entries: [entry], policy: .after(nextReload))) + } + + private func entry(date: Date) -> CapgoTemplateWidgetEntry { + do { + let layout = try bridge.resolveLatestLayout(surface: surface, status: status, bundle: bundle, now: date) + return CapgoTemplateWidgetEntry(date: date, layout: layout) + } catch { + logTemplateHomeWidgetResolutionFailure(error, surface: surface, bundle: bundle, status: status) + return CapgoTemplateWidgetEntry(date: date, layout: nil) + } + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateHomeWidgetView: View { + private let entry: CapgoTemplateWidgetEntry + private let showsHotspots: Bool + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + entry: CapgoTemplateWidgetEntry, + showsHotspots: Bool = true, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.entry = entry + self.showsHotspots = showsHotspots + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + CapgoTemplateSurfaceView( + layout: entry.layout, + showsHotspots: showsHotspots, + svgContent: svgContent, + placeholder: placeholder + ) + .widgetURL(entry.layout?.openUrl.flatMap(URL.init(string:))) + } +} + +@available(iOS 16.0, *) +private enum CapgoTemplateHomeWidgetLog { + static let logger = Logger(subsystem: "app.capgo.widgetkit", category: "TemplateHomeWidget") +} + +@available(iOS 16.0, *) +private func logTemplateHomeWidgetResolutionFailure( + _ error: Error, + surface: CapgoTemplateSurface, + bundle: Bundle, + status: String? +) { + let statusContext = status ?? "any" + let bundleIdentifier = bundle.bundleIdentifier ?? "unknown" + let context = "status=\(statusContext) surface=\(surface.rawValue) bundle=\(bundleIdentifier)" + CapgoTemplateHomeWidgetLog.logger.error( + "Failed to resolve Home Screen widget template \(context, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) +} +#endif diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift index f835364..f242fa5 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift @@ -2,6 +2,7 @@ import Foundation public enum CapgoTemplateSurface: String, CaseIterable, Sendable { + case homeScreen case lockScreen case dynamicIslandExpanded case dynamicIslandCompactLeading @@ -126,10 +127,9 @@ public final class CapgoTemplateWidgetBridge { } let record = try TemplateRuntimeRecord(envelope: envelope) - let layoutKey = surface.rawValue guard let layouts = record.definition["layouts"] as? [String: Any], - let layoutObject = layouts[layoutKey] as? [String: Any] + let layoutObject = layoutObject(for: surface, in: layouts) else { return nil } @@ -168,6 +168,16 @@ public final class CapgoTemplateWidgetBridge { return try resolveLayout(activityId: envelope.activityId, surface: surface, bundle: bundle, now: now) } + private func layoutObject(for surface: CapgoTemplateSurface, in layouts: [String: Any]) -> [String: Any]? { + if let layout = layouts[surface.rawValue] as? [String: Any] { + return layout + } + if surface == .homeScreen { + return layouts[CapgoTemplateSurface.lockScreen.rawValue] as? [String: Any] + } + return nil + } + private func parseHotspots(_ value: Any?) -> [CapgoTemplateResolvedHotspot] { guard let hotspots = value as? [[String: Any]] else { return [] diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift index 65732c1..45b70f2 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift @@ -8,6 +8,7 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "areActivitiesSupported", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "startTemplateActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "startTemplateWidget", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "updateTemplateActivity", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endTemplateActivity", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "performTemplateAction", returnType: CAPPluginReturnPromise), @@ -35,6 +36,14 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { } @objc func startTemplateActivity(_ call: CAPPluginCall) { + startTemplateRecord(call, startLiveActivity: call.getBool("startLiveActivity") ?? true) + } + + @objc func startTemplateWidget(_ call: CAPPluginCall) { + startTemplateRecord(call, startLiveActivity: false) + } + + private func startTemplateRecord(_ call: CAPPluginCall, startLiveActivity: Bool) { guard let definition = call.getObject("definition") else { call.reject(CapgoWidgetKitBridgeError.missingObject("definition").localizedDescription) return @@ -52,7 +61,7 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { definitionObject: definition, stateObject: state, openUrl: call.getString("openUrl"), - startLiveActivity: call.getBool("startLiveActivity") ?? true + startLiveActivity: startLiveActivity ) call.resolve(payload) } catch { diff --git a/package.json b/package.json index 5d3fcea..76423c3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@capgo/capacitor-widget-kit", "version": "8.1.2", "private": false, - "description": "Capacitor plugin for generic iOS WidgetKit and Live Activities using SVG templates, declarative actions, and shared App Group persistence.", + "description": "Capacitor plugin for generic iOS Home Screen widgets, WidgetKit, and Live Activities using SVG templates, declarative actions, and shared App Group persistence.", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", @@ -30,6 +30,7 @@ "capacitor", "plugin", "widgetkit", + "home-screen-widgets", "live-activities", "activitykit", "ios" diff --git a/src/definitions.ts b/src/definitions.ts index 9d96e41..5830397 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -222,9 +222,16 @@ export type SvgTemplateLayout = SvgTemplateLayoutWithSvg | SvgTemplateLayoutWith */ export interface SvgTemplateLayouts { /** - * Primary lock-screen / banner layout. + * Optional Home Screen / SpringBoard widget layout. + * + * When omitted, native Home Screen widgets may fall back to `lockScreen`. + */ + homeScreen?: SvgTemplateLayout; + + /** + * Optional lock-screen / Live Activity banner layout. */ - lockScreen: SvgTemplateLayout; + lockScreen?: SvgTemplateLayout; /** * Optional expanded Dynamic Island layout. @@ -251,6 +258,7 @@ export interface SvgTemplateLayouts { * Named WidgetKit surface for one SVG layout variant. */ export type SvgTemplateSurface = + | 'homeScreen' | 'lockScreen' | 'dynamicIslandExpanded' | 'dynamicIslandCompactLeading' @@ -713,6 +721,34 @@ export interface StartTemplateActivityOptions { startLiveActivity?: boolean; } +/** + * Options for starting or replacing a Home Screen / SpringBoard widget template. + * + * This persists the same SVG template record as `startTemplateActivity`, but native iOS + * implementations do not start an ActivityKit Live Activity. + */ +export interface StartTemplateWidgetOptions { + /** + * Optional explicit activity identifier. When omitted, the native runtime creates one. + */ + activityId?: string; + + /** + * Generic SVG template definition. + */ + definition: SvgTemplateDefinition; + + /** + * Initial JSON state exposed under `state.*`. + */ + state: SvgTemplateState; + + /** + * Optional deep link used when the widget body is tapped. + */ + openUrl?: string; +} + /** * Result when starting a generic template activity. */ @@ -1238,14 +1274,15 @@ export interface ReloadWidgetsOptions { /** * Capacitor bridge for an iOS-first WidgetKit / Live Activities plugin. * - * The core abstraction is a generic SVG template activity: + * The core abstraction is a generic SVG template record: * - raw SVG templates with binding placeholders * - declarative action patches * - timer bindings exposed to the template scope * - event logging so the host app can process button results later * * The plugin owns shared persistence, declarative action execution, and event retrieval. - * The host widget extension keeps full freedom over actual WidgetKit rendering. + * The host widget extension keeps full freedom over actual WidgetKit rendering, including + * Home Screen / SpringBoard widgets backed by WidgetKit timelines. * * Full-native widgets can use widget sessions for synchronous shared state and widget messages * for asynchronous app/widget jobs without adopting the SVG template renderer. @@ -1261,6 +1298,11 @@ export interface CapgoWidgetKitPlugin { */ startTemplateActivity(options: StartTemplateActivityOptions): Promise; + /** + * Persist a generic SVG template for Home Screen / SpringBoard widgets without starting a Live Activity. + */ + startTemplateWidget(options: StartTemplateWidgetOptions): Promise; + /** * Replace part or all of the stored activity definition/state. */ diff --git a/src/helpers/workout.ts b/src/helpers/workout.ts index 441c563..d0a2355 100644 --- a/src/helpers/workout.ts +++ b/src/helpers/workout.ts @@ -125,6 +125,34 @@ export function createWorkoutTemplateActivity(session: WorkoutTemplateSession): }, ], layouts: { + homeScreen: { + width: 160, + height: 160, + hotspots: [ + { + id: 'complete-button', + actionId: 'complete-set', + x: 104, + y: 116, + width: 38, + height: 28, + label: 'Complete active set', + role: 'button', + }, + ], + svg: ` + + + + {{state.session.title}} + {{state.currentExercise.title}} + {{state.currentExercise.subtitle}} + {{state.currentSet.title}} + {{timers.rest.remainingText}} + + +`.trim(), + }, lockScreen: { width: 100, height: 40, diff --git a/src/runtime.ts b/src/runtime.ts index bb6393e..1c93272 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -543,7 +543,7 @@ function frameIdsForMutation(activity: SvgTemplateActivityRecord, mutation: SvgT return []; } - return activity.definition.layouts[surface]?.frames?.map((frame) => frame.id) ?? []; + return getTemplateLayout(activity, surface)?.frames?.map((frame) => frame.id) ?? []; } function normalizeFrameMutationId(frameId: string | undefined, frameIds: string[]): string | undefined { @@ -745,7 +745,10 @@ export function getTemplateLayout( activity: SvgTemplateActivityRecord, surface: SvgTemplateSurface, ): SvgTemplateLayout | null { - return activity.definition.layouts[surface] ?? null; + return ( + activity.definition.layouts[surface] ?? + (surface === 'homeScreen' ? (activity.definition.layouts.lockScreen ?? null) : null) + ); } export function resolveTemplateSurface( diff --git a/src/web.ts b/src/web.ts index 913d78a..00a4d6a 100644 --- a/src/web.ts +++ b/src/web.ts @@ -23,6 +23,7 @@ import type { SendWidgetMessageOptions, StartTemplateActivityOptions, StartTemplateActivityResult, + StartTemplateWidgetOptions, StartWidgetSessionOptions, StartWidgetSessionResult, StopWidgetSessionOptions, @@ -137,7 +138,7 @@ export class CapgoWidgetKitWeb extends WebPlugin implements CapgoWidgetKitPlugin return { supported: false, reason: - 'WidgetKit preview mode only. The generic template runtime works in the browser, but native ActivityKit support is available on iOS 16.2+.', + 'WidgetKit preview mode only. The generic template runtime works in the browser, but Home Screen widgets and ActivityKit require native iOS or Android runtimes.', }; } @@ -152,6 +153,10 @@ export class CapgoWidgetKitWeb extends WebPlugin implements CapgoWidgetKitPlugin return { activity: cloneJson(activity) }; } + async startTemplateWidget(options: StartTemplateWidgetOptions): Promise { + return this.startTemplateActivity(options); + } + async updateTemplateActivity(options: UpdateTemplateActivityOptions): Promise { const store = loadStore(); const current = getActivityOrThrow(store, options.activityId);