# 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`. Both
components render the same indeterminate-ring visual language and share semantic
color tokens, simplified small-size motion, and a reduced-motion fallback.
Sizes, base motion timings, and public prop names intentionally follow each
platform's needs.

This is one half of the paired Indicators system described in
`docs/component_upgrades/Indicators.proposal.md`. Use `ProgressIndicator` for
determinate cases where the completed fraction is known.

The change that introduced this component (motivation, decisions, alternatives
considered) is recorded in
`openspec/changes/archive/2026-06-10-add-activityindicator-mobile/`. The current
web contract is in `openspec/specs/activity-indicator/spec.md`, with additional
implementation guidance in `packages/components/src/ActivityIndicator/guide.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

`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 non-interactive. It exposes one accessible progress element and
does not require keyboard or screen-reader interaction.

## 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` 4 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 behaviorally
aligned with web's small indicator while retaining mobile-appropriate sizing.

The native base durations match Material Web's published progress motion spec
and are provided by `@jobber/design`'s `timing-indicator-*` tokens. Web scales
those timings by 1.15× for a calmer desktop rhythm, so the base motion is
deliberately similar rather than identical. The small spin duration is `1000ms`
on both platforms.

## 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 and Android. Mobile deliberately uses larger
dimensions than web (28/44px here versus 16/32px on web) and does not adopt
iOS-native sizing (`UIActivityIndicatorView`'s ~20/~37 px). The implementation
still avoids platform-specific rendering branches.

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 and behavioral consistency with the web `ActivityIndicator` is the
primary goal. Both components share:

- Material Design 3 three-layer indeterminate ring geometry
- 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)
- Layer 1-only motion for the small size, with a 1000ms spin

Documented divergences:

| Aspect                   | Web                                                                          | Mobile                                                                                                                   |
| ------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Sizes                    | 16px small, 32px base                                                        | 28px small, 44px base                                                                                                    |
| Base motion              | Tuned 1803 / 6132 / 1533ms CSS literals                                      | Material 1568 / 5332 / 1333ms `timing-indicator-*` tokens                                                                |
| Accessible label         | `ariaLabel`                                                                  | `accessibilityLabel`                                                                                                     |
| Default label            | `useAtlantisI18n(commonMessages)`                                            | `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" />` — remains available during the migration
  window and 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.
