@colixsystems/widget-sdk 0.118.0 → 0.119.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
35
35
  | **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
36
36
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
37
37
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
38
- | **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
38
+ | **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). **Background watch (sc-6450)** — `startBackgroundWatch({ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs })` keeps positions arriving while the app is backgrounded; `stopBackgroundWatch()` releases it. NATIVE-ONLY and opt-in per app: gate the control on `backgroundSupported` (false on web, and in an export whose workspace did not opt in). The watch outlives the widget's mount, and its positions land in the same `latitude`/`longitude`/`accuracy` slots. |
39
39
  | **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
40
40
  | **CORE** | `useCamera(options?)` | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (file input) and the Expo export (`expo-image-picker`). |
41
41
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
@@ -70,8 +70,41 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.118.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
73
+ `v0.119.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
74
74
 
75
+ ### What's new in 0.119.0 (contract 1.91.0)
76
+
77
+ **`useGeolocation()` can now track location while the app is BACKGROUNDED (sc-6450).** The hook only ever read a position while the app was in the foreground, so the whole class of field-work apps — delivery tracking, site visits, mileage and timesheet logging — could not be built. Its result gains four members; the existing foreground API is untouched:
78
+
79
+ | Member | Shape | Notes |
80
+ | --- | --- | --- |
81
+ | `backgroundSupported` | `boolean` | **Check this before rendering the control.** |
82
+ | `backgroundWatching` | `boolean` | Whether a watch is running on this device. |
83
+ | `startBackgroundWatch` | `(options?) => Promise<void>` | `options`: `{ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }`. |
84
+ | `stopBackgroundWatch` | `() => Promise<void>` | Releases the OS subscription. |
85
+
86
+ ```jsx
87
+ const { latitude, longitude, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch } = useGeolocation();
88
+
89
+ {backgroundSupported && (
90
+ <Button
91
+ label={backgroundWatching ? "Stop trip" : "Start trip"}
92
+ onPress={() => (backgroundWatching ? stopBackgroundWatch() : startBackgroundWatch({ distanceIntervalMeters: 50 }))}
93
+ />
94
+ )}
95
+ ```
96
+
97
+ The watch is **native-only and opt-in per app**. `backgroundSupported` is `false` on the web Player — a browser tab genuinely cannot track in the background, the same capability-gated honesty `useSpeechToText` applies on Firefox — and it is also `false` in an exported app whose workspace has not enabled background location in **Publishing Settings**. That opt-in exists so an app that never uses the capability declares no background mode and stays clear of the extra store review.
98
+
99
+ Three behaviours to design around. Tracking covers the app **running in the background**; it does not survive the OS terminating the app, so don't promise an unattended log. The watch **outlives the widget's mount** — that is the point — so it is released only by `stopBackgroundWatch()`, never on unmount; `backgroundWatching` is seeded from the host so a remounted widget reports a running watch honestly. And background positions land in the **same** `latitude` / `longitude` / `accuracy` slots as the foreground read, so a widget renders one position regardless of how it arrived.
100
+
101
+ `startBackgroundWatch()` rejects with the existing `GeolocationError`: `.code` `UNSUPPORTED` when the host or build does not offer the capability, `PERMISSION_DENIED` when the user refuses always-on location. On Android 11+ the always-on grant cannot be made from a runtime dialog — the user has to pick "Allow all the time" in system Settings — so treat `PERMISSION_DENIED` as a prompt to explain that, not as a dead end.
102
+
103
+ `backgroundWatching` is a mirror of the host, not of your calls: the OS can end the watch on its own (a permission downgrade, a killed foreground service) and a sibling widget can start or stop it, so render from the flag rather than from whether you called `start`.
104
+
105
+ **Store review:** Apple and Google both require a *visible user benefit* plus a justification for background location. Your app must show the user that tracking is running and let them stop it, and your store listing must explain why the app needs it. A build that turns the opt-in on without that is rejected at review.
106
+
107
+ `CONTRACT.version` → `1.91.0`. Additive — four new result members and six new optional `ctx.device.geolocation` broker members; no existing export changed signature.
75
108
  ### What's new in 0.118.0 (contract 1.90.0)
76
109
 
77
110
  **A `styleSchema` field's `default` now actually applies.** Declaring `default` on a style field wrote it into the manifest and nothing ever read it back, so a widget's own styling baseline — and any design saved from the Widget Builder preview — was silently dropped on the next render. The host now resolves it onto `props.style`.
package/dist/contract.cjs CHANGED
@@ -1570,7 +1570,17 @@ const HOOKS = [
1570
1570
  "permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to { latitude, longitude, accuracy } " +
1571
1571
  "and stores the same values on the hook; it rejects with a GeolocationError whose .code is one of PERMISSION_DENIED | " +
1572
1572
  "UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL. options pass through to the host ({ enableHighAccuracy, timeout, " +
1573
- "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location).",
1573
+ "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location). " +
1574
+ "BACKGROUND WATCH (sc-6450): startBackgroundWatch(options?) keeps positions arriving while the app is BACKGROUNDED — the " +
1575
+ "field-work case (delivery tracking, site visits, mileage logging) — and stopBackgroundWatch() releases the OS " +
1576
+ "subscription. It is NATIVE-ONLY and opt-in per app: ALWAYS check `backgroundSupported` before rendering the control, " +
1577
+ "because the web Player reports false (a browser tab cannot track in the background) and so does an exported app whose " +
1578
+ "workspace has not enabled background location in Publishing Settings. The watch OUTLIVES the widget mount by design, so " +
1579
+ "it stops ONLY on stopBackgroundWatch(); `backgroundWatching` is seeded from the host so a remounted widget reports it " +
1580
+ "honestly. Background positions land in the SAME latitude/longitude/accuracy slots. startBackgroundWatch rejects with a " +
1581
+ "GeolocationError (.code UNSUPPORTED when the host or build does not offer it, PERMISSION_DENIED when the user refuses " +
1582
+ "always-on location). options: { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }. Tracking continues while the " +
1583
+ "app is running in the BACKGROUND; it does not survive the OS terminating the app, so do not promise an unattended log.",
1574
1584
  returnShape: {
1575
1585
  latitude: "number | null",
1576
1586
  longitude: "number | null",
@@ -1579,6 +1589,13 @@ const HOOKS = [
1579
1589
  error: "GeolocationError | null",
1580
1590
  getCurrentPosition:
1581
1591
  "() => Promise<{ latitude, longitude, accuracy }> // rejects with GeolocationError",
1592
+ backgroundSupported:
1593
+ "boolean // GATE THE CONTROL ON THIS: false on the web Player and in an export that did not opt in",
1594
+ backgroundWatching: "boolean // a background watch is running on this device",
1595
+ startBackgroundWatch:
1596
+ "(options?) => Promise<void> // NATIVE-ONLY; { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }; rejects with GeolocationError",
1597
+ stopBackgroundWatch:
1598
+ "() => Promise<void> // the ONLY release - the watch outlives the widget's mount",
1582
1599
  },
1583
1600
  requiredContextSlice: [],
1584
1601
  scopes: null,
@@ -2259,7 +2276,10 @@ const WIDGET_CONTEXT_SHAPE = {
2259
2276
  device: {
2260
2277
  description:
2261
2278
  "Optional host-brokered device capabilities. " +
2262
- "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
2279
+ "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }>, " +
2280
+ "isBackgroundSupported() -> boolean, startBackgroundWatch(options?) -> Promise<void>, stopBackgroundWatch() -> Promise<void>, " +
2281
+ "isBackgroundWatching() -> boolean, subscribeBackgroundPositions(cb) -> unsubscribe, " +
2282
+ "subscribeBackgroundWatchState(cb) -> unsubscribe }, " +
2263
2283
  "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
2264
2284
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
2265
2285
  "Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
@@ -2269,7 +2289,14 @@ const WIDGET_CONTEXT_SHAPE = {
2269
2289
  "speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
2270
2290
  "its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
2271
2291
  "camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
2272
- "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
2292
+ "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). " +
2293
+ "sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
2294
+ "did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
2295
+ "expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
2296
+ "sensor and prompt for nothing, so the watch can outlive any one widget mount. The HOST owns whether a watch is running: " +
2297
+ "it is primed asynchronously after a cold relaunch, the OS can end it on its own (a permission downgrade, a killed " +
2298
+ "foreground service), and a sibling widget may start or stop it — so subscribeBackgroundWatchState is how every mounted " +
2299
+ "widget stays truthful, and isBackgroundWatching() is only the synchronous first read.",
2273
2300
  required: false,
2274
2301
  fields: { geolocation: "object", speech: "object", camera: "object" },
2275
2302
  },
@@ -3527,7 +3554,21 @@ const CONTRACT = deepFreeze({
3527
3554
  // theme still outranks a widget's own baseline and a field the author never
3528
3555
  // defaulted follows the theme exactly as before. The host still never
3529
3556
  // applies style to elements — the widget owns placement.
3530
- version: "1.90.0",
3557
+ // 1.91.0: additive (sc-6450) — the geolocation BACKGROUND watch:
3558
+ // `useGeolocation()` gains backgroundSupported / backgroundWatching /
3559
+ // startBackgroundWatch / stopBackgroundWatch, and the optional
3560
+ // `device.geolocation` broker gains isBackgroundSupported,
3561
+ // startBackgroundWatch, stopBackgroundWatch, isBackgroundWatching,
3562
+ // subscribeBackgroundPositions and subscribeBackgroundWatchState. Field-work
3563
+ // apps — delivery tracking, site visits, mileage — could not be built at all
3564
+ // while a position could only be read in the foreground. Native-only and
3565
+ // opt-in per app: the web Player reports backgroundSupported false because a
3566
+ // tab is suspended once backgrounded, and so does an export whose workspace
3567
+ // did not opt in. The HOST owns whether a watch is running (it is primed
3568
+ // asynchronously after an OS relaunch, the OS can end it, and a sibling
3569
+ // widget can start or stop it), so subscribeBackgroundWatchState is how every
3570
+ // mounted widget stays truthful.
3571
+ version: "1.91.0",
3531
3572
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3532
3573
  hooks: HOOKS,
3533
3574
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1570,7 +1570,17 @@ const HOOKS = [
1570
1570
  "permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to { latitude, longitude, accuracy } " +
1571
1571
  "and stores the same values on the hook; it rejects with a GeolocationError whose .code is one of PERMISSION_DENIED | " +
1572
1572
  "UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL. options pass through to the host ({ enableHighAccuracy, timeout, " +
1573
- "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location).",
1573
+ "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location). " +
1574
+ "BACKGROUND WATCH (sc-6450): startBackgroundWatch(options?) keeps positions arriving while the app is BACKGROUNDED — the " +
1575
+ "field-work case (delivery tracking, site visits, mileage logging) — and stopBackgroundWatch() releases the OS " +
1576
+ "subscription. It is NATIVE-ONLY and opt-in per app: ALWAYS check `backgroundSupported` before rendering the control, " +
1577
+ "because the web Player reports false (a browser tab cannot track in the background) and so does an exported app whose " +
1578
+ "workspace has not enabled background location in Publishing Settings. The watch OUTLIVES the widget mount by design, so " +
1579
+ "it stops ONLY on stopBackgroundWatch(); `backgroundWatching` is seeded from the host so a remounted widget reports it " +
1580
+ "honestly. Background positions land in the SAME latitude/longitude/accuracy slots. startBackgroundWatch rejects with a " +
1581
+ "GeolocationError (.code UNSUPPORTED when the host or build does not offer it, PERMISSION_DENIED when the user refuses " +
1582
+ "always-on location). options: { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }. Tracking continues while the " +
1583
+ "app is running in the BACKGROUND; it does not survive the OS terminating the app, so do not promise an unattended log.",
1574
1584
  returnShape: {
1575
1585
  latitude: "number | null",
1576
1586
  longitude: "number | null",
@@ -1579,6 +1589,13 @@ const HOOKS = [
1579
1589
  error: "GeolocationError | null",
1580
1590
  getCurrentPosition:
1581
1591
  "() => Promise<{ latitude, longitude, accuracy }> // rejects with GeolocationError",
1592
+ backgroundSupported:
1593
+ "boolean // GATE THE CONTROL ON THIS: false on the web Player and in an export that did not opt in",
1594
+ backgroundWatching: "boolean // a background watch is running on this device",
1595
+ startBackgroundWatch:
1596
+ "(options?) => Promise<void> // NATIVE-ONLY; { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }; rejects with GeolocationError",
1597
+ stopBackgroundWatch:
1598
+ "() => Promise<void> // the ONLY release - the watch outlives the widget's mount",
1582
1599
  },
1583
1600
  requiredContextSlice: [],
1584
1601
  scopes: null,
@@ -2259,7 +2276,10 @@ const WIDGET_CONTEXT_SHAPE = {
2259
2276
  device: {
2260
2277
  description:
2261
2278
  "Optional host-brokered device capabilities. " +
2262
- "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
2279
+ "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }>, " +
2280
+ "isBackgroundSupported() -> boolean, startBackgroundWatch(options?) -> Promise<void>, stopBackgroundWatch() -> Promise<void>, " +
2281
+ "isBackgroundWatching() -> boolean, subscribeBackgroundPositions(cb) -> unsubscribe, " +
2282
+ "subscribeBackgroundWatchState(cb) -> unsubscribe }, " +
2263
2283
  "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
2264
2284
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
2265
2285
  "Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
@@ -2269,7 +2289,14 @@ const WIDGET_CONTEXT_SHAPE = {
2269
2289
  "speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
2270
2290
  "its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
2271
2291
  "camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
2272
- "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
2292
+ "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). " +
2293
+ "sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
2294
+ "did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
2295
+ "expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
2296
+ "sensor and prompt for nothing, so the watch can outlive any one widget mount. The HOST owns whether a watch is running: " +
2297
+ "it is primed asynchronously after a cold relaunch, the OS can end it on its own (a permission downgrade, a killed " +
2298
+ "foreground service), and a sibling widget may start or stop it — so subscribeBackgroundWatchState is how every mounted " +
2299
+ "widget stays truthful, and isBackgroundWatching() is only the synchronous first read.",
2273
2300
  required: false,
2274
2301
  fields: { geolocation: "object", speech: "object", camera: "object" },
2275
2302
  },
@@ -3527,7 +3554,21 @@ const CONTRACT = deepFreeze({
3527
3554
  // theme still outranks a widget's own baseline and a field the author never
3528
3555
  // defaulted follows the theme exactly as before. The host still never
3529
3556
  // applies style to elements — the widget owns placement.
3530
- version: "1.90.0",
3557
+ // 1.91.0: additive (sc-6450) — the geolocation BACKGROUND watch:
3558
+ // `useGeolocation()` gains backgroundSupported / backgroundWatching /
3559
+ // startBackgroundWatch / stopBackgroundWatch, and the optional
3560
+ // `device.geolocation` broker gains isBackgroundSupported,
3561
+ // startBackgroundWatch, stopBackgroundWatch, isBackgroundWatching,
3562
+ // subscribeBackgroundPositions and subscribeBackgroundWatchState. Field-work
3563
+ // apps — delivery tracking, site visits, mileage — could not be built at all
3564
+ // while a position could only be read in the foreground. Native-only and
3565
+ // opt-in per app: the web Player reports backgroundSupported false because a
3566
+ // tab is suspended once backgrounded, and so does an export whose workspace
3567
+ // did not opt in. The HOST owns whether a watch is running (it is primed
3568
+ // asynchronously after an OS relaunch, the OS can end it, and a sibling
3569
+ // widget can start or stop it), so subscribeBackgroundWatchState is how every
3570
+ // mounted widget stays truthful.
3571
+ version: "1.91.0",
3531
3572
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3532
3573
  hooks: HOOKS,
3533
3574
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -987,9 +987,55 @@ function toGeolocationError(err) {
987
987
  return new GeolocationError(code, message, { cause: err });
988
988
  }
989
989
 
990
+ /** Normalise a host position onto the hook's three numeric slots. */
991
+ function normalizeGeolocationPosition(pos) {
992
+ return {
993
+ latitude: pos && typeof pos.latitude === "number" ? pos.latitude : null,
994
+ longitude: pos && typeof pos.longitude === "number" ? pos.longitude : null,
995
+ accuracy: pos && typeof pos.accuracy === "number" ? pos.accuracy : null,
996
+ };
997
+ }
998
+
999
+ /**
1000
+ * Ask the host whether a background watch is running. The watch outlives the
1001
+ * widget's mount, so the host — not the hook — owns this truth.
1002
+ */
1003
+ function readBackgroundWatching(client) {
1004
+ if (!client || typeof client.isBackgroundWatching !== "function") return false;
1005
+ try {
1006
+ return Boolean(client.isBackgroundWatching());
1007
+ } catch {
1008
+ return false;
1009
+ }
1010
+ }
1011
+
1012
+ /** Whether this host offers the background watch. Never throws at render. */
1013
+ function readBackgroundSupported(client) {
1014
+ if (!client || typeof client.startBackgroundWatch !== "function") return false;
1015
+ if (typeof client.isBackgroundSupported !== "function") return true;
1016
+ try {
1017
+ return Boolean(client.isBackgroundSupported());
1018
+ } catch {
1019
+ return false;
1020
+ }
1021
+ }
1022
+
1023
+ /** Subscribe defensively; a host that throws simply yields no subscription. */
1024
+ function safeSubscribe(client, method, handler) {
1025
+ if (!client || typeof client[method] !== "function") return null;
1026
+ try {
1027
+ const unsubscribe = client[method](handler);
1028
+ return typeof unsubscribe === "function" ? unsubscribe : null;
1029
+ } catch {
1030
+ return null;
1031
+ }
1032
+ }
1033
+
990
1034
  /**
991
1035
  * Read the device's current position. Returns
992
- * `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }`.
1036
+ * `{ latitude, longitude, accuracy, loading, error, getCurrentPosition,
1037
+ * backgroundSupported, backgroundWatching, startBackgroundWatch,
1038
+ * stopBackgroundWatch }`.
993
1039
  *
994
1040
  * Capture is IMPERATIVE — call `getCurrentPosition()` from a user gesture (a
995
1041
  * tap on a button). Browsers and the mobile OS gate the permission prompt on a
@@ -1004,6 +1050,21 @@ function toGeolocationError(err) {
1004
1050
  * Safe-by-default: on a host that does not inject `ctx.device.geolocation`,
1005
1051
  * `getCurrentPosition()` rejects with `code: "UNSUPPORTED"` rather than
1006
1052
  * throwing at render, so a widget can call the hook unconditionally.
1053
+ *
1054
+ * BACKGROUND WATCH (sc-6450) — `startBackgroundWatch(options?)` keeps positions
1055
+ * arriving while the app is BACKGROUNDED, which is what field-work apps
1056
+ * (delivery tracking, site visits, mileage logging) need. It is capability-
1057
+ * gated: check `backgroundSupported` before offering the control. The web
1058
+ * Player reports false — a browser tab cannot track in the background — and so
1059
+ * does an exported app whose workspace has not opted into background location
1060
+ * in Publishing Settings, because a build that never uses it must declare no
1061
+ * background mode and stay clear of the extra store review.
1062
+ *
1063
+ * The watch OUTLIVES the widget's mount by design, so it is released only by
1064
+ * `stopBackgroundWatch()`, never on unmount; `backgroundWatching` is seeded
1065
+ * from the host so a remounted widget reports a running watch honestly.
1066
+ * Delivered positions land in the SAME `latitude` / `longitude` / `accuracy`
1067
+ * slots as the foreground read.
1007
1068
  */
1008
1069
  export function useGeolocation(options) {
1009
1070
  const ctx = useWidgetContextOrThrow("useGeolocation");
@@ -1019,6 +1080,45 @@ export function useGeolocation(options) {
1019
1080
  optionsRef.current = options;
1020
1081
  const runRef = useRef(0);
1021
1082
 
1083
+ const client = ctx.device && ctx.device.geolocation;
1084
+ const backgroundSupported = readBackgroundSupported(client);
1085
+ const [backgroundWatching, setBackgroundWatching] = useState(() =>
1086
+ readBackgroundWatching(client),
1087
+ );
1088
+
1089
+ // Attach to the host's two background channels on MOUNT rather than inside
1090
+ // startBackgroundWatch: the watch survives unmount, so a remounted widget
1091
+ // must still receive its positions. Subscribing prompts for nothing and
1092
+ // starts no sensor — only startBackgroundWatch() does, from a user gesture.
1093
+ //
1094
+ // The host is the SINGLE source of `backgroundWatching`: the OS can end the
1095
+ // watch on its own (a permission downgrade, a killed service), the answer is
1096
+ // primed asynchronously after a cold relaunch, and a sibling widget may start
1097
+ // or stop it — none of which this hook could observe on its own. Keyed on the
1098
+ // client so a host that injects the slice late still gets wired up.
1099
+ useEffect(() => {
1100
+ if (!client) return undefined;
1101
+ setBackgroundWatching(readBackgroundWatching(client));
1102
+ const unsubscribers = [
1103
+ safeSubscribe(client, "subscribeBackgroundPositions", (pos) => {
1104
+ setCoords(normalizeGeolocationPosition(pos));
1105
+ }),
1106
+ safeSubscribe(client, "subscribeBackgroundWatchState", (watching) => {
1107
+ setBackgroundWatching(Boolean(watching));
1108
+ }),
1109
+ ];
1110
+ return () => {
1111
+ for (const unsubscribe of unsubscribers) {
1112
+ if (!unsubscribe) continue;
1113
+ try {
1114
+ unsubscribe();
1115
+ } catch {
1116
+ /* the host already tore the subscription down */
1117
+ }
1118
+ }
1119
+ };
1120
+ }, [client]);
1121
+
1022
1122
  const getCurrentPosition = useCallback(async () => {
1023
1123
  const myRun = ++runRef.current;
1024
1124
  const client = clientRef.current;
@@ -1037,14 +1137,7 @@ export function useGeolocation(options) {
1037
1137
  setError(null);
1038
1138
  try {
1039
1139
  const pos = await client.getCurrentPosition(optionsRef.current);
1040
- const next = {
1041
- latitude:
1042
- pos && typeof pos.latitude === "number" ? pos.latitude : null,
1043
- longitude:
1044
- pos && typeof pos.longitude === "number" ? pos.longitude : null,
1045
- accuracy:
1046
- pos && typeof pos.accuracy === "number" ? pos.accuracy : null,
1047
- };
1140
+ const next = normalizeGeolocationPosition(pos);
1048
1141
  if (runRef.current !== myRun) return next;
1049
1142
  setCoords(next);
1050
1143
  setLoading(false);
@@ -1059,6 +1152,48 @@ export function useGeolocation(options) {
1059
1152
  }
1060
1153
  }, []);
1061
1154
 
1155
+ const startBackgroundWatch = useCallback(async (watchOptions) => {
1156
+ const client = clientRef.current;
1157
+ if (!client || typeof client.startBackgroundWatch !== "function") {
1158
+ const e = new GeolocationError(
1159
+ "UNSUPPORTED",
1160
+ "This host does not track location in the background.",
1161
+ );
1162
+ setError(e);
1163
+ throw e;
1164
+ }
1165
+ setError(null);
1166
+ try {
1167
+ await client.startBackgroundWatch(watchOptions);
1168
+ } catch (err) {
1169
+ const ge = toGeolocationError(err);
1170
+ setError(ge);
1171
+ throw ge;
1172
+ } finally {
1173
+ // Re-read rather than assume: the host knows whether the OS actually
1174
+ // took the subscription, and a failed start may still leave one.
1175
+ setBackgroundWatching(readBackgroundWatching(client));
1176
+ }
1177
+ }, []);
1178
+
1179
+ const stopBackgroundWatch = useCallback(async () => {
1180
+ const client = clientRef.current;
1181
+ if (!client || typeof client.stopBackgroundWatch !== "function") {
1182
+ setBackgroundWatching(false);
1183
+ return;
1184
+ }
1185
+ try {
1186
+ await client.stopBackgroundWatch();
1187
+ } catch (err) {
1188
+ const ge = toGeolocationError(err);
1189
+ setError(ge);
1190
+ throw ge;
1191
+ } finally {
1192
+ // A REFUSED stop leaves the watch running; only the host knows.
1193
+ setBackgroundWatching(readBackgroundWatching(client));
1194
+ }
1195
+ }, []);
1196
+
1062
1197
  return {
1063
1198
  latitude: coords ? coords.latitude : null,
1064
1199
  longitude: coords ? coords.longitude : null,
@@ -1066,6 +1201,10 @@ export function useGeolocation(options) {
1066
1201
  loading,
1067
1202
  error,
1068
1203
  getCurrentPosition,
1204
+ backgroundSupported,
1205
+ backgroundWatching,
1206
+ startBackgroundWatch,
1207
+ stopBackgroundWatch,
1069
1208
  };
1070
1209
  }
1071
1210
 
package/dist/index.d.ts CHANGED
@@ -645,6 +645,29 @@ export interface WidgetContext<TProps = unknown> {
645
645
  longitude: number;
646
646
  accuracy: number;
647
647
  }>;
648
+ /** sc-6450 — false on web and on an export that did not opt in. */
649
+ isBackgroundSupported?(): boolean;
650
+ startBackgroundWatch?(
651
+ options?: BackgroundLocationOptions,
652
+ ): Promise<void>;
653
+ stopBackgroundWatch?(): Promise<void>;
654
+ isBackgroundWatching?(): boolean;
655
+ /** Attach to the running watch; starts no sensor and prompts for nothing. */
656
+ subscribeBackgroundPositions?(
657
+ onPosition: (pos: {
658
+ latitude: number;
659
+ longitude: number;
660
+ accuracy: number;
661
+ }) => void,
662
+ ): () => void;
663
+ /**
664
+ * sc-6450 — the host pushes whether a watch is running: it is primed
665
+ * asynchronously after a cold relaunch, the OS can end it on its own, and
666
+ * a sibling widget may start or stop it.
667
+ */
668
+ subscribeBackgroundWatchState?(
669
+ onChange: (watching: boolean) => void,
670
+ ): () => void;
648
671
  };
649
672
  };
650
673
  }
@@ -1429,6 +1452,15 @@ export interface GeolocationOptions {
1429
1452
  maximumAge?: number;
1430
1453
  }
1431
1454
 
1455
+ /** sc-6450 — pass-through options for `startBackgroundWatch(...)`. */
1456
+ export interface BackgroundLocationOptions {
1457
+ enableHighAccuracy?: boolean;
1458
+ /** Report only after the device has moved this far, in metres. */
1459
+ distanceIntervalMeters?: number;
1460
+ /** Report no more often than this, in milliseconds. */
1461
+ timeIntervalMs?: number;
1462
+ }
1463
+
1432
1464
  export interface GeolocationResult {
1433
1465
  latitude: number | null;
1434
1466
  longitude: number | null;
@@ -1446,6 +1478,26 @@ export interface GeolocationResult {
1446
1478
  longitude: number;
1447
1479
  accuracy: number;
1448
1480
  }>;
1481
+ /**
1482
+ * sc-6450 — whether this host can track location while BACKGROUNDED. False
1483
+ * on the web Player and on an exported app whose workspace did not opt into
1484
+ * background location. Check it before rendering the control.
1485
+ */
1486
+ backgroundSupported: boolean;
1487
+ /** Whether a background watch is currently running on this device. */
1488
+ backgroundWatching: boolean;
1489
+ /**
1490
+ * Start tracking while backgrounded — call from a user gesture. The watch
1491
+ * OUTLIVES the widget's mount; only `stopBackgroundWatch()` releases it.
1492
+ * Delivered positions land in the same `latitude`/`longitude`/`accuracy`
1493
+ * slots. Rejects with a `GeolocationError`.
1494
+ *
1495
+ * Tracking continues while the app RUNS in the background; it does not
1496
+ * survive the OS terminating the app.
1497
+ */
1498
+ startBackgroundWatch(options?: BackgroundLocationOptions): Promise<void>;
1499
+ /** Release the OS subscription. */
1500
+ stopBackgroundWatch(): Promise<void>;
1449
1501
  }
1450
1502
 
1451
1503
  /**
@@ -1455,6 +1507,9 @@ export interface GeolocationResult {
1455
1507
  * `navigator.geolocation`, the Expo export via `expo-location`. Safe to call on
1456
1508
  * a host that doesn't broker geolocation: `getCurrentPosition()` then rejects
1457
1509
  * with `code: "UNSUPPORTED"`.
1510
+ *
1511
+ * sc-6450 — the same hook also drives the native-only background watch; see
1512
+ * `backgroundSupported` / `startBackgroundWatch` on the result.
1458
1513
  */
1459
1514
  export function useGeolocation(options?: GeolocationOptions): GeolocationResult;
1460
1515
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.118.0",
3
+ "version": "0.119.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",