# ActivityIndicator Spec (mobile)

## Purpose

`ActivityIndicator` communicates an **indeterminate** activity the user cannot
directly control or measure — loading, fetching, or waiting on an external
system. It is a non-interactive Atom in `@jobber/components-native`.

This component is the mobile counterpart of the web `ActivityIndicator` shipped
in atlantis change `add-activityindicator`. Both components render the Material
Design 3 three-layer indeterminate ring, with the same sizes, motion durations,
color tokens, and reduced-motion fallback. Public prop names diverge to follow
each platform's native conventions (`accessibilityLabel` here vs. `ariaLabel` on
web; `style` here vs. `className` + `style` on web).

This is one half of the paired Indicators system described in
`docs/component_upgrades/Indicators.proposal.md`. The other half,
`ProgressIndicator`, will own determinate-controlled cases (progress with a
known fraction) on both platforms and is forthcoming.

The change that introduced this component (motivation, decisions, alternatives
considered) is recorded in `openspec/changes/add-activityindicator-mobile/`. The
web spec is at
`packages/components/src/ActivityIndicator/ActivityIndicator.spec.md`.

## API

```ts
interface ActivityIndicatorProps {
  /** Visual size. base = 44px, small = 28px. Defaults to "base". */
  readonly size?: "small" | "base";

  /**
   * Accessible label. Defaults to a localized "Loading" string via
   * useAtlantisI18n("loading").
   */
  readonly accessibilityLabel?: string;

  /** Inline styles applied to the root view. */
  readonly style?: StyleProp<ViewStyle>;

  /** Test identifier. Defaults to "ActivityIndicator". */
  readonly testID?: string;
}
```

### Removed from the previous wrapper

The previous wrapper accepted React Native's full `ActivityIndicatorProps` via
spread. The new public API drops the following intentionally:

- `color` — replaced by semantic theme tokens (`color-text--secondary` for the
  arcs, `color-surface--active` for the track). The single known consumer of
  `color="white"` (`jobber-mobile/GalleryHeader`) is migrated as part of the
  `migrate-activityindicator` change in the jobber-mobile repository.
- numeric `size` — sizing is constrained to the two design-system sizes (28px /
  44px). Pixel-precise overrides are out of scope for the Indicators v1.
- `hidesWhenStopped`, `animating` — the new component is always animating while
  rendered; visibility is the consumer's responsibility via conditional
  rendering.
- Other React Native `View` / `ActivityIndicatorProps` pass-throughs — `style`
  and `testID` are the two preserved escape hatches.

### Deprecated alias (this release only)

`size` accepts `"large"` as a deprecated alias for `"base"`. The TypeScript type
unions `"small" | "base" | "large"`; the `"large"` branch carries a JSDoc
`@deprecated` annotation. Passing `"large"` maps to `"base"` at runtime and
emits a `console.warn` in `__DEV__` builds. The alias is removed in a follow-up
atlantis change (`drop-activityindicator-legacy-size`) once external consumers
have migrated.

## Accessibility

The root view carries:

- `accessibilityRole="progressbar"` — semantic match for an indeterminate
  ongoing activity (and the role React Native's own `ActivityIndicator`
  primitive uses internally).
- `accessibilityState={{ busy: true }}` — signals to TalkBack / VoiceOver that
  an indeterminate process is in progress.
- `accessibilityLabel` — resolved from the prop, falling back to
  `useAtlantisI18n("loading")` (the existing translation key already used by
  `Form/FormMask`).

On mount and on every `accessibilityLabel` change, the component calls
`AccessibilityInfo.announceForAccessibility(label)` so VoiceOver and TalkBack
announce the label once. This mirrors the web spec's "announce-on-mount"
behavior for VoiceOver — the platform-specific plumbing differs (web uses
visually-hidden text inside a `role="status"` live region) but the user-visible
behavior is identical.

The component does **not** set `accessibilityLiveRegion`. With both an
imperative announce and an Android live region present, Android would
double-announce on mount. The imperative call is uniform across iOS and Android;
the live region is platform-specific. Dropping the latter keeps the rendering
path free of `Platform.OS` branches.

The indicator is not focusable and is not in the keyboard / screen reader
navigation order beyond the initial announcement.

## Animation Model

The default (`base`) presentation renders Material Design 3's three-layer
indeterminate ring. The implementation uses `react-native-svg` for the ring
geometry and `react-native-reanimated` 3 for the motion — all worklet-driven so
the animation runs on the UI thread regardless of JS thread load.

The three concurrent animations (base only):

1. A wrapping `Animated.View` rotates continuously at a constant linear speed
   driven by `tokens["timing-indicator--linear-rotate"]` (1568ms per
   revolution). This is the "outer" rotation that prevents the ring from ever
   sitting still.

2. An inner transform rotates through 1080° per cycle on a
   `tokens["timing-indicator--cycle"]` (5332ms) cubic-bezier easing curve
   approximating Material Web's `indicatorRotateArc` keyframe. Combined with
   Layer 1, this produces the eight-phase rhythm that reads as non-periodic.

3. An animated `<Circle>` (via `Animated.createAnimatedComponent(Circle)`) has
   its `strokeDasharray` and `strokeDashoffset` driven by `useAnimatedProps` on
   a `tokens["timing-indicator--arc"]` (1333ms) sub-cycle, so the visible arc
   grows from "barely visible" to roughly 270° of the ring and shrinks back.

A static `<Circle>` of the same dimensions renders the track ring beneath the
active arcs, filled with `themeTokens["color-surface--active"]` so the track
adapts to whichever surface (`surface`, `surface--background`,
`surface--background--subtle`) the indicator is placed on.

### Size → motion config

Motion timing is looked up from `MOTION_BY_SIZE` (alongside `SIZE_PX` /
`STROKE_WIDTH`), not from size-name booleans. Each entry declares `layers` (`1`
= fixed arc / Layer 1 only; `3` = full Material ring), `linearRotateMs`, and —
for fixed-arc sizes — a static `dasharray`. A future size is an additive map
entry rather than nested `isSmall` / `isLarge` branches.

### Small motion (parity with web)

`size="small"` deliberately does **not** run Layers 2 and 3. Matching web's
`ActivityIndicator.module.css` (`.small .svg` / `.small .arc`):

- Only Layer 1 runs — a fixed-length arc (`strokeDasharray: 40, 200`) spun by
  the outer rotation.
- Layer 1 duration is `1000ms` (~1.8× faster than web's base linear rotate), so
  the simpler single arc still feels lively.
- The 8-phase inner rotation and expand/contract arc are omitted because that
  layered motion reads as too busy at 28px.

This keeps FormatFile grid thumbnails and other small call sites visually
aligned with web's small indicator, not just size-matched.

The literal base durations match Material Web's published progress motion spec;
the same values ship from `@jobber/design`'s `timing.tokens.json` to both web
and mobile so the base rhythm is identical across platforms. The small spin
duration is a CSS literal on web and a matching constant on native (`1000ms`).

## Reduced Motion

When the operating system reports reduce-motion is enabled, the component:

- Skips rendering the rotating layers and rocking arcs.
- Renders a single static `<Circle>` ring in `tokens["color-text--secondary"]`.
  The ring stands in for the hidden arcs, so it deliberately tracks the arc
  colour.
- Animates the ring's opacity between 1.0 and 0.35 over a 4-second ease-in-out
  alternate cycle (2s down, 2s up) so the indicator still reads as "busy"
  without rotational motion.

Detection uses `useReducedMotion()` from `react-native-reanimated`. The pulse
animation opts OUT of Reanimated's automatic reduce-motion suppression via
`ReduceMotion.Never` on each `withTiming` and on the wrapping `withRepeat`.
Without this opt-out, Reanimated snaps to the target value instantly when the OS
setting is on — which is the default everyone wants for normal animations, but
is exactly the wrong behaviour for the pulse, because the pulse IS our
reduce-motion fallback. The implementation file documents this in an inline
comment.

The pulse range (1.0 ↔ 0.35) matches the corrected range the web component
shipped — the original 0.55 lower bound was too subtle to read as activity.

## Sizing

| `size`  | Outer diameter | Stroke width                        | Motion                                       |
| ------- | -------------- | ----------------------------------- | -------------------------------------------- |
| `small` | 28 px          | proportional to web's small variant | Layer 1 only; fixed arc; 1000ms spin         |
| `base`  | 44 px          | proportional to web's base variant  | Full three-layer Material indeterminate ring |

Sizes are unified across iOS, Android, and web. The component deliberately does
not adopt iOS-native sizing (`UIActivityIndicatorView`'s ~20/~37 px) — the
cross-platform parity argument wins over Apple HIG convention here, consistent
with the "one implementation, no platform branch" stance.

Layout — inline-vs-block placement, margins, alignment — is the consumer's
responsibility. The component has no `inline` prop. Wrap in a `<View>`,
`<Flex>`, or your own container.

## Cross-platform parity

Visual + behavioral parity with the web `ActivityIndicator` is the primary goal.
Both components share:

- Material Design 3 three-layer indeterminate ring geometry
- Sizes (28 px small, 44 px base)
- Motion durations (`timing-indicator-arc`, `timing-indicator-cycle`,
  `timing-indicator-linear-rotate`) from the same `@jobber/design` source
- Color tokens (`color-text--secondary` for arcs, `color-surface--active` for
  track) from the same `@jobber/design` source
- Reduced-motion fallback (opacity 1.0 ↔ 0.35, 2 s ease-in-out alternate)

Documented divergences:

| Aspect                   | Web                                                                          | Mobile                                                                                                                   |
| ------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Accessible label         | `ariaLabel`                                                                  | `accessibilityLabel`                                                                                                     |
| Default label            | hardcoded `"Loading"`                                                        | `useAtlantisI18n("loading")`                                                                                             |
| Styling                  | `className` + `style`                                                        | `style`                                                                                                                  |
| Test id                  | (no equivalent; tests use `getByRole` / `getByText`)                         | `testID`                                                                                                                 |
| Accessibility plumbing   | `role="status"` + visually-hidden text + announce-on-mount fix for VoiceOver | `accessibilityRole="progressbar"` + `accessibilityState={{ busy: true }}` + `AccessibilityInfo.announceForAccessibility` |
| Reduced motion detection | CSS `@media (prefers-reduced-motion: reduce)`                                | `useReducedMotion()` from `react-native-reanimated`                                                                      |
| Small motion             | Layer 1 only; fixed arc; 1000ms spin (CSS)                                   | Same behavior (Reanimated); matches web intentionally                                                                    |

The divergences are intentional — each platform follows its own conventions on
the user-facing side while the underlying intent and visual outcome match.

## Migration

The previous `ActivityIndicator` in `@jobber/components-native` was a thin
wrapper around React Native's primitive. Migration of existing call sites:

- Bare `<ActivityIndicator />` — no change needed. The new component renders a
  (better-looking) Material ring at the base size.
- `<ActivityIndicator size="small" />` — no change needed.
- `<ActivityIndicator size="large" />` — runs unchanged for this release; warns
  in development. Update to `size="base"` at your convenience.
- `<ActivityIndicator color="white" />` — no longer supported. The single known
  site (`jobber-mobile/GalleryHeader`) is migrated by the
  `migrate-activityindicator` change in the jobber-mobile repository.
- `<ActivityIndicator size={42} />` — numeric sizes are no longer supported.
  Pick `"small"` or `"base"`.
- `<ActivityIndicator hidesWhenStopped={true} animating={false} />` — no longer
  supported. Use conditional rendering at the call site
  (`{loading && <ActivityIndicator />}`).
- `<ActivityIndicator accessibilityLabel={formatMessage(messages.loading)} />` —
  no longer necessary if the consumer's message is semantically identical to
  "Loading"; the component now defaults to the localized `loading` string. Keep
  the override if the message is specific (e.g., `"Uploading"`).

The legacy `size="large"` alias is removed in a follow-up atlantis change
(`drop-activityindicator-legacy-size`) once external consumers have migrated.
Until then, no breakage; only a deprecation warning in development.
