Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- feat(ui): add `ios-widgets` skill for MetaMask Mobile home screen widgets and Live Activities, replacing the repo-local `.cursor/rules/widget-development.mdc` ([#110](https://github.com/MetaMask/skills/pull/110)).
- Add opt-in stale project skill pruning via `--prune-stale` and `SKILLS_PRUNE_STALE=1`.
- docs(testing): document CV stale-press flakiness plus high-leverage assert patterns (migration parity, filter both-sides example, loading/skeleton honesty, RefreshControl + flag overrides) in `mobile-testing` component-view and placement refs.

Expand Down
159 changes: 159 additions & 0 deletions domains/ui/skills/ios-widgets/references/adding-a-widget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Adding a home screen widget

A widget needs both a JS half (layout + registration + data) and a native half
(a Swift file in the WidgetKit extension target). Both halves are keyed on one
string: the widget's name.

Work through these in order. Steps 5 to 8 are the ones a JS-only change will
forget, and their absence shows up as "the widget never appears in the
gallery".

## 1. Design the props

A flat, JSON-serializable interface. Pre-formatted, pre-translated, pre-masked
— see **Designing props** in the skill body.

## 2. Layout and registration — `app/core/Widgets/widgets/MyWidget.ios.tsx`

```tsx
import { Text, VStack } from '@expo/ui/swift-ui';
import { font, foregroundStyle, padding } from '@expo/ui/swift-ui/modifiers';
import type { WidgetEnvironment } from 'expo-widgets';

import { createMetaMaskWidget } from '../createMetaMaskWidget.ios';
import type { WithWidgetTheme } from '../types';

export interface MyWidgetProps {
/** Already formatted and privacy-mode aware. Computed by WidgetUpdaterService. */
valueDisplay: string;
label: string;
}

function MyWidgetLayout(
props: MyWidgetProps & WithWidgetTheme,
environment: WidgetEnvironment,
) {
'widget';

const { valueDisplay, label, theme } = props;
const activeTheme =
environment.colorScheme === 'dark' ? theme.dark : theme.light;

return (
<VStack
alignment="leading"
spacing={activeTheme.spacing.xs}
modifiers={[padding({ all: activeTheme.spacing.md })]}
>
<Text modifiers={[foregroundStyle(activeTheme.colors.textAlternative)]}>
{label}
</Text>
<Text
modifiers={[
foregroundStyle(activeTheme.colors.textDefault),
font({
size: activeTheme.typography.amountDisplay.size,
weight: activeTheme.typography.amountDisplay.weight,
}),
]}
>
{valueDisplay}
</Text>
</VStack>
);
}

export const MY_WIDGET_NAME = 'MyWidget';

export const MyWidget = createMetaMaskWidget<MyWidgetProps>(
MY_WIDGET_NAME,
MyWidgetLayout,
);
```

Keep the layout dumb: destructure, arrange, return. The `'widget'` directive
must be the first statement in the function body.

## 3. Fallback — `app/core/Widgets/widgets/MyWidget.tsx`

Duplicate `MyWidgetProps`, re-export the same name constant, and register a
no-op layout through the **extensionless** wrapper:

```tsx
import { createMetaMaskWidget } from '../createMetaMaskWidget';

export interface MyWidgetProps {
valueDisplay: string;
label: string;
}

export const MY_WIDGET_NAME = 'MyWidget';

export const MyWidget = createMetaMaskWidget<MyWidgetProps>(
MY_WIDGET_NAME,
() => undefined,
);
```

`.tsx`, not `.ts`, even with no JSX in it. See **Platform split** in the skill
body — a `.ts` fallback silently shadows the real widget on iOS.

## 4. Data — `app/core/Widgets/WidgetUpdaterService.ts`

Add one `private computeMyWidgetProps()` and one
`private pushMyWidgetUpdate()`, and call the push method from
`pushUpdates()`. Every selector read, formatter call, privacy-mode mask, and
`strings()` lookup belongs here — never in the widget file.

The service already debounces store changes (2s) and skips the native write
when the newly computed props are `JSON.stringify`-identical to the last push.
Do not push from anywhere else to work around that.

## 5. Swift file — `ios/ExpoWidgetsTarget/MyWidget.swift`

Copy `BalanceWidget.swift` and replace `name` (must exactly equal
`MY_WIDGET_NAME`), `configurationDisplayName`, `description`, and
`supportedFamilies`.

## 6. Bundle entry — `ios/ExpoWidgetsTarget/index.swift`

Add `MyWidget()` to the `WidgetBundle` body. WidgetKit caps one bundle at
**4 widgets**; a fifth needs a chained nested bundle (see the comments in that
file).

## 7. Declare it in `app.config.js`

Add an entry to the `expo-widgets` plugin's `widgets` array mirroring the
Swift file's `name`, `displayName`, `description`, `supportedFamilies`, and
`contentMarginsDisabled`.

This entry is **never evaluated** — the repo has a checked-in `ios/` directory,
so `expo prebuild` never runs and the config plugin never executes. It exists
as the canonical human-readable declaration, the same way `expo-font`'s
`fonts` array is declared beside a hand-committed `UIAppFonts` list. Keeping
it in sync matters because a future `expo prebuild` would regenerate
`ios/ExpoWidgetsTarget/` from it alone.

## 8. Xcode target membership

The new `.swift` file must be a **member of the `ExpoWidgetsTarget` target**,
not merely present on disk (Xcode → File Inspector → Target Membership).
`scripts/ios/setup-expo-widgets-target.rb` does not help: it early-returns
once the target exists.

## 9. Tests

See [`testing.md`](testing.md).

## 10. Verify in a simulator

Full rebuild (widget code lives in a separate native binary, so a Metro reload
is not enough), then long-press the home screen → **+** → search the widget's
display name. Check light and dark mode, and that it updates after the
underlying data changes in-app.

## No analytics step

Adoption is tracked automatically, keyed on the WidgetKit `kind` — which is
already `MY_WIDGET_NAME`. A new widget is measured the moment its Swift file
exists and a user places it. There is nothing to add.
152 changes: 152 additions & 0 deletions domains/ui/skills/ios-widgets/references/live-activities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Adding a Live Activity

A Live Activity is the Lock Screen and Dynamic Island counterpart of a home
screen widget. Every rule in the skill body applies unchanged: the `'widget'`
directive and its no-closures consequence, the `.ios.tsx` + `.tsx` pair, both
theme variants, pre-formatted props.

Three things differ, and they are the whole delta.

## 1. No native work at all

No `.swift` file, no `index.swift` entry, no Xcode target membership, no
`app.config.js` entry. `expo-widgets`' generic `WidgetLiveActivity()` renderer
is already in the bundle and `NSSupportsLiveActivities` is already set.
`createLiveActivity(name, layout)` writes the stringified layout into the
shared App Group container at **import time**, and the extension reads it back
by `name` at render time.

A new Live Activity is a pure-JS change and ships over Metro/OTA like any
other JS. No rebuild is needed to see it in a simulator that already has the
extension installed — reloading JS is enough, because registration happens at
import.

## 2. The layout returns an object, not a JSX tree

A widget layout returns one view. A Live Activity layout returns a
`LiveActivityLayout` whose keys are the presentation regions iOS asks for:

| Key | Where it appears |
| --- | --- |
| `banner` | Lock Screen and Notification Center |
| `bannerSmall` | CarPlay / watchOS (falls back to `banner`) |
| `compactLeading` | Collapsed Dynamic Island, left of the camera |
| `compactTrailing` | Collapsed Dynamic Island, right of the camera |
| `minimal` | Dynamic Island when another app shares it |
| `expandedLeading` / `expandedTrailing` / `expandedCenter` / `expandedBottom` | Long-pressed Dynamic Island |

The second parameter is a `LiveActivityEnvironment`, not a
`WidgetEnvironment` — same `colorScheme`, no `widgetFamily`. `expo-widgets`
does not re-export the type from its package root, so import it from
`../createMetaMaskLiveActivity.ios`, which derives and re-exports it.

```tsx
import { Text } from '@expo/ui/swift-ui';
import { foregroundStyle } from '@expo/ui/swift-ui/modifiers';

import {
createMetaMaskLiveActivity,
type LiveActivityEnvironment,
} from '../createMetaMaskLiveActivity.ios';
import type { WithWidgetTheme } from '../types';

export interface MyActivityProps {
valueDisplay: string;
/** Semantic, not a resolved color — the layout still has to pick light/dark. */
isProfit: boolean;
}

function MyActivityLayout(
props: MyActivityProps & WithWidgetTheme,
environment: LiveActivityEnvironment,
) {
'widget';

const { valueDisplay, isProfit, theme } = props;
const activeTheme =
environment.colorScheme === 'dark' ? theme.dark : theme.light;
const valueColor = isProfit
? activeTheme.colors.success
: activeTheme.colors.error;

const value = (
<Text modifiers={[foregroundStyle(valueColor)]}>{valueDisplay}</Text>
);

return {
banner: value,
compactLeading: value,
compactTrailing: value,
minimal: value,
};
}

export const MY_ACTIVITY_NAME = 'MyActivity';

export const MyActivity = createMetaMaskLiveActivity<MyActivityProps>(
MY_ACTIVITY_NAME,
MyActivityLayout,
);
```

The plain `MyActivity.tsx` fallback mirrors this through
`../createMetaMaskLiveActivity` with a `() => undefined` layout, same as a
widget's fallback.

## 3. The lifecycle is feature-owned

`WidgetUpdaterService` exists to fan one debounced Redux snapshot out to
widgets. A Live Activity is a state machine — start on open, update while
open, end on close — and its data is often not in Redux at all. So the owning
feature drives it, from its own service.

That service must:

- Gate on `Platform.OS === 'ios'` and `process.env.MM_WIDGETS_ENABLED === 'true'`.
- Call `endLiveActivitiesFromPreviousLaunch()` once when it starts (see
[Orphaned activities](#orphaned-activities) below).
- **Throttle, then dedupe.** ActivityKit budgets update frequency, and a price
feed ticks far faster than a Lock Screen card is worth redrawing. Throttle
the source subscriptions, then skip the write entirely when the newly
computed props are `JSON.stringify`-identical to the last push — the same
pattern `WidgetUpdaterService` uses.
- End with `.end('immediate')`.

A Live Activity can only be **started** while the app is foregrounded.

## Orphaned activities

`expo-widgets` renders every Live Activity through a single shared
`ActivityAttributes` type, discriminating kinds only by a `name` string inside
the content state. Two consequences:

- **`getInstances()` on any factory returns every live instance app-wide**,
regardless of which factory started it, and `update()` rewrites that `name`
from the factory the handle came from. Never adopt `getInstances()[0]` — you
may silently repurpose another feature's activity.
- iOS keeps a Live Activity alive after its host app is terminated, but the JS
handle needed to end it does not survive. `reconcileLiveActivities.ts`
therefore ends **everything**, once per process, at launch — before any
feature has started an activity of its own. It is guarded so a second caller
cannot end the first caller's freshly started activity.

## Privacy mode

Suppress the activity outright rather than masking the numbers. A Lock Screen
is readable without unlocking the device. This is a deliberate difference from
a home screen widget, which masks instead.

## Checklist

1. Design the props — flat, pre-formatted, semantic flags over resolved
colors.
2. `app/core/Widgets/liveActivities/MyActivity.ios.tsx` — props, `'widget'`
layout returning a `LiveActivityLayout`, `createMetaMaskLiveActivity(...)`.
3. `app/core/Widgets/liveActivities/MyActivity.tsx` — no-op fallback, `.tsx`
extension.
4. A feature-owned lifecycle service: `.start()` / `.update()` /
`.end('immediate')`, throttled and deduped, gated on platform and flag,
calling `endLiveActivitiesFromPreviousLaunch()` once.
5. Tests — see [`testing.md`](testing.md).
6. Verify in a simulator. No rebuild needed if the extension is already
installed.
72 changes: 72 additions & 0 deletions domains/ui/skills/ios-widgets/references/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Testing widgets and Live Activities

A `'widget'`-directive function becomes a **string literal** at build time,
and `babel.config.tests.js` uses the same preset — so the transform runs under
Jest too. Importing the module and calling the layout does not execute your
JSX; it returns a string. React Testing Library cannot help here.

Test each layer at the boundary where it is still real code.

## What to test where

| Layer | What is assertable |
| --- | --- |
| `WidgetTheme.ts` | Pure functions: color and typography mapping, spacing scale, font-weight fallback, singleton exports |
| `createMetaMaskWidget.ios.ts` / `createMetaMaskLiveActivity.ios.ts` | Delegation to `expo-widgets`' `createWidget` / `createLiveActivity` with the right `(name, layout)` |
| The plain `.ts` fallbacks | Every method is a safe no-op and never throws |
| A widget's own `*.ios.tsx` | Registration only: the name matches the Swift file, and the layout argument is now a `string` |
| `WidgetUpdaterService.ts` | All of the real logic: formatting, privacy masking, debounce, redundant-push skipping, cleanup |
| A Live Activity's lifecycle service | All of the real logic: start/update/end transitions, throttling, dedupe, flag and platform gating |

## The registration test

Small, but it is the regression check that the Babel transform actually ran —
if someone breaks the preset config, the layout stays a function and the app
ships a widget that cannot render:

```ts
expect(createWidget).toHaveBeenCalledWith('MyWidget', expect.any(String));
```

Assert the name matches the Swift file's `kind`, and that the returned object
exposes `updateSnapshot` / `reload`. Nothing about rendered UI is assertable
from this file.

## The logic test

This is where the coverage actually belongs. Mock `ReduxService.store`, the
selectors, and the widget module (`jest.mock('./widgets/MyWidget', ...)`),
then drive the captured `store.subscribe` listener with `jest.useFakeTimers()`
to assert debounce, skip, and cleanup behavior. `WidgetUpdaterService.test.ts`
is the full worked pattern.

For a Live Activity, the equivalent is the feature's lifecycle service: drive
its data source, then assert `.start()` / `.update()` / `.end()` calls and
that a duplicate payload produces no second write.

## Jest infrastructure

Native-module mocks are registered explicitly in `jest.config.js`'s
`moduleNameMapper` — this project does not rely on the auto-discovered root
`__mocks__/` convention:

- `app/__mocks__/expo-widgets.ts`
- `app/__mocks__/@expo/ui/swift-ui.ts`
- `app/__mocks__/@expo/ui/swift-ui-modifiers.ts`

They exist so the **import statements** at the top of `.ios.tsx` files resolve
without the real module's throwing `requireNativeModule`. The stubbed
components are never invoked, since the JSX never executes under Jest.

Importing an `@expo/ui` submodule that has no stub yet throws at require time.
Add the `moduleNameMapper` entry next to the existing ones and create the
matching stub in `app/__mocks__/@expo/ui/`.

## Testing the feature flag

`jest.config.js` defaults `MM_WIDGETS_ENABLED` to `'true'` so the enabled path
is the default under test. Because the value is inlined by
`transform-inline-environment-variables` at transform time, a test can only
toggle it at runtime if **both the module under test and its test file** are
in `babel.config.tests.js`'s inline-env `exclude` list. Add both when a new
service reads the flag and you want to cover the disabled no-op path.
Loading