# Home-screen widget

Glide has an iOS home-screen widget built with **`expo-widgets`**, living in
`src/widget/`. It mirrors the Action surface outside the app and updates in real
time whenever the app is (or has recently been) running.

## Files

- `glide-widget.tsx` — the widget UI. `createWidget('GlideWidget', fn)`, where `fn`
  carries the `'widget'` directive. Renders native SwiftUI via `@expo/ui/swift-ui`
  + `/modifiers`. One definition renders all three families (small / medium /
  large); branch on `environment.widgetFamily`.
- `widget-payload.ts` — `toWidgetPayload(board)`, a pure function from the local
  board (`useBoard`) to the flat, serialisable payload the widget renders.
- `use-glide-widget-sync.ios.ts` (+ a no-op `.ts` base) — reads the local board via
  `useBoard` and pushes `GlideWidget.updateSnapshot(payload)` on change
  (JSON-deduped). Mounted as `<WidgetSync/>` in `src/app/_layout.tsx`. This is the
  "real-time" channel — no APNs; it works while the app is or has been running. See
  `docs/engineering/local-first.md` for the data layer it reads from.
- `app.json` → `expo-widgets` plugin config: widget `name` (`GlideWidget`, must
  match the `createWidget` id), `bundleIdentifier`, `groupIdentifier` (App Group),
  and `supportedFamilies`.

## How the UI reaches the device

The babel `widgets-plugin` **serialises the `'widget'` function body to a source
string at build time**. The app ships that string to native via `createWidget`,
and it runs in a **separate WidgetKit JS runtime** where the `@expo/ui` primitives
are injected as globals. The app pushes data with `updateSnapshot(props)`;
WidgetKit persists the last snapshot and renders it.

Consequence: **editing the widget UI is JS-only** — just reload Metro, no native
rebuild. (`ios/` is generated from `app.json` via CNG; the widget target
regenerates on prebuild / EAS.)

## Gotchas (each one cost real debugging time)

- **The widget body must be fully self-contained.** It may only reference its
  `props` / `environment`, plain JS built-ins, and the injected `@expo/ui`
  globals. No module-level imports, constants, or helpers referenced at runtime —
  define helper components **inside** the function.
- **React Compiler breaks it.** `experiments.reactCompiler` injects a `_c` memo
  cache into the inner helpers → runtime `Can't find variable: _c`. The `'widget'`
  directive only opts out the outer function, so the file starts with a
  module-level **`'use no memo'`** directive.
- **The payload must be string/number-only — never `null`/`undefined`.**
  `updateSnapshot` crosses into a native host function that can't convert a `null`
  prop value; it throws `Exception in HostFunction`, and because `<WidgetSync/>`
  is mounted high in the tree that throw is a **full-app render crash**. The
  "current task" field is therefore `''` (not `null`) when nothing is ready, and
  the push in `use-glide-widget-sync.ios.ts` is wrapped in try/catch so a
  peripheral widget failure can never crash the app.
- **App Group signing on the simulator.** App ↔ widget data sharing needs the App
  Group entitlement (`group.com.h14h.glide`) on **both** targets. Simulator builds
  with no dev team drop it at signing → widget error "No layout found for
  `group.com.h14h.glide::GlideWidget`". This affects **EAS `--profile development`
  (`ios.simulator`) builds too**, not just local Xcode ones — check with
  `codesign -d --entitlements :- <app>` and `…/PlugIns/*.appex` (both come back with
  an empty `<dict/>`). Sim workaround:
  1. Re-sign ad-hoc, inside-out: every nested framework/dylib in `<app>/Frameworks`
     (`codesign --force --sign -`), then the appex, then the app — the latter two
     with `--entitlements` carrying the App Group.
  2. **Reinstall** (`xcrun simctl install <udid> <app>`). Re-signing in place is not
     enough: `installd` only provisions the shared App Group container
     (`Containers/Shared/AppGroup/…`) on install. Verify with
     `xcrun simctl get_app_container <udid> com.h14h.glide group.com.h14h.glide`.
  3. Relaunch so the app pushes a fresh snapshot into the now-shared container.

  Production / store EAS builds sign correctly and need none of this.
- **`@expo/ui/swift-ui` rendering quirks** (the app's "Pop" look — thick rounded
  border + hard offset shadow — is rebuilt from shapes):
  - SwiftUI `.shadow` shadows the **text too**. For a box-shadow, put a shadowed
    `RoundedRectangle` *behind* the content in a `ZStack`.
  - Don't use `capsule` shapes for bordered pills — layered capsule fills render a
    deviant concentric double outline. Use `RoundedRectangle` with a near-pill
    radius.
  - `ZStack` shape backgrounds are greedy (no intrinsic size) → add
    `fixedSize({ horizontal: false, vertical: true })` so a card hugs its content.
  - Children must be **flat keyed arrays** (no fragments; native flattens only one
    array level).

## Task title length

Task titles are capped at **`TITLE_MAX_LENGTH = 64`** (`src/domain/glide.ts`,
enforced in the local store `src/data/task-store.ts`). The binding constraint is the
2×2 small widget; if you change the cap, re-stress-test that size.

## Android

Deferred — **blocked upstream**, not by us. `expo-widgets` ships no working
Android widget yet; the pieces that exist are scaffolding only. Verified against
both the pinned `56.0.22` and the latest published `57.0.6` (2026-07-24):

- `ExpoWidgetsGlanceWidget.kt` renders a hardcoded `Text(widgetName)` — an
  installed widget would show the literal string "GlideWidget", forever.
- Android's `WidgetsModule.kt` declares only `Name("ExpoWidgets")`. There is no
  `Widget` shared object and no `updateTimeline`, so there is no JS → native
  data path.
- Android has no `ExpoWidgets.android.ts`, so it resolves to `ExpoWidgets.ts` —
  a no-op stub whose `updateTimeline` does nothing and whose `widgetsDirectory`
  is `''`.
- The plugin's Android half is gated behind `enableAndroid` (**default
  `false`**), added in 56.0.17 as an explicitly "temporarily opt-in" state. It
  generates a provider class, `appwidget-provider` XML, strings, and a manifest
  receiver — the plumbing, none of the rendering.

There is no `@expo/ui` → Glance renderer, so nothing in `glide-widget.tsx`
reaches an Android device. **Do not set `enableAndroid: true`** until the
renderer lands: it only makes the placeholder installable.

Upstream is actively building this (the Android base package and config plugin
landed in 56.0.15–56.0.17). When the renderer and native module ship, the work
here should be small — the payload and `toWidgetPayload` are already
platform-agnostic, and `use-glide-widget-sync.ts` is a deliberate no-op base
that an `.android.ts` sibling can replace the same way `.ios.ts` does. Re-check
`ExpoWidgetsGlanceWidget.kt` and `WidgetsModule.kt` in a new release to tell
whether it has actually landed; the changelog has not called it out.

The alternative — a hand-written Glance widget behind a local config plugin —
was considered and declined: it would add a permanent Kotlin surface that
forfeits the iOS side's JS-only editing, and Glance has no true shadow/stroke
primitive, so the Pop look could only be approximated.

## macOS

Deferred (a native Mac target may come later). The widget UI and payload are kept
platform-agnostic to ease that.
