import React, { useEffect } from "react"; import { AccessibilityInfo } from "react-native"; import type { StyleProp, ViewStyle } from "react-native"; import Animated, { Easing, ReduceMotion, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming, } from "react-native-reanimated"; import Svg, { Circle } from "react-native-svg"; import { tokens } from "../utils/design"; import { useAtlantisTheme } from "../AtlantisThemeContext"; import { useAtlantisI18n } from "../hooks/useAtlantisI18n"; const AnimatedCircle = Animated.createAnimatedComponent(Circle); /** * The deprecated `"large"` value is preserved here as a typed alias for * `"base"`. It will be removed in a follow-up atlantis change once * downstream consumers (notably jobber-mobile) migrate. New code should * use `"base"`. * * @deprecated */ type DeprecatedSize = "large"; export interface ActivityIndicatorProps { /** * Visual size. `"base"` renders at 44px, `"small"` renders at 28px. * `"large"` is a deprecated alias for `"base"` and will be removed in a * future release. * * @default "base" */ readonly size?: "small" | "base" | DeprecatedSize; /** * Accessible label exposed to assistive technology and announced on * mount. Defaults to a localized "Loading" string via * `useAtlantisI18n("loading")`. */ readonly accessibilityLabel?: string; /** * Inline styles applied to the root view. */ readonly style?: StyleProp; /** * Test identifier applied to the root view. Defaults to * `"ActivityIndicator"`. */ readonly testID?: string; } type ResolvedSize = "small" | "base"; const SIZE_PX = { small: 28, base: 44 } as const; // Stroke widths are expressed in viewBox units (the SVG is 48 across). // The small variant uses a thicker stroke in viewBox units to compensate // for its smaller rendered diameter, keeping the visual stroke thickness // roughly constant across sizes. Matches the web component's CSS. const STROKE_WIDTH = { small: 6, base: 4 } as const; const VIEWBOX = 48; const CENTER = 24; const RADIUS = 20; // Circle circumference (2π·20 ≈ 125.66) drives the dasharray math below. // "200" used as the gap value exceeds the circumference, ensuring only // one stroke segment is visible at a time. const GAP = 200; /** * Per-size motion profile. Adding a size is an additive map entry — not a * new boolean branch. `layers: 1` is Layer 1 only (fixed arc); `layers: 3` * runs the full Material 3 indeterminate ring (Layers 1–3). * * Small matches web's ActivityIndicator.module.css (`.small .svg` / * `.small .arc`): Layer 1 only, spun ~1.8× faster. Web hardcodes 1000ms * (≈1803ms ÷ 1.8); we match that absolute duration. */ const MOTION_BY_SIZE = { small: { layers: 1 as const, linearRotateMs: 1000, dasharray: `40, ${GAP}`, }, base: { layers: 3 as const, linearRotateMs: tokens["timing-indicator--linear-rotate"], }, } satisfies Record< ResolvedSize, | { layers: 1; linearRotateMs: number; dasharray: string } | { layers: 3; linearRotateMs: number } >; function resolveLegacySize( size: "small" | "base" | DeprecatedSize, ): ResolvedSize { if (size === "large") { if (__DEV__) { console.warn( '[ActivityIndicator] size="large" is deprecated. Use size="base" instead.', ); } return "base"; } return size; } function useAnnounceOnMount(label: string) { useEffect(() => { AccessibilityInfo.announceForAccessibility(label); }, [label]); } /** * `ActivityIndicator` communicates an indeterminate activity that the * user cannot directly control or measure — loading, fetching, or * waiting on an external system. * * Renders the Material Design 3 three-layer indeterminate ring, * matching the visual identity of the web `ActivityIndicator` from * `@jobber/components`. For determinate progress, use * `ProgressIndicator` (forthcoming). */ export function ActivityIndicator({ size = "base", accessibilityLabel, style, testID = "ActivityIndicator", }: ActivityIndicatorProps) { const resolvedSize = resolveLegacySize(size); const { tokens: themeTokens } = useAtlantisTheme(); const { t } = useAtlantisI18n(); const resolvedLabel = accessibilityLabel ?? t("loading"); useAnnounceOnMount(resolvedLabel); const reducedMotion = useReducedMotion(); const pixelSize = SIZE_PX[resolvedSize]; const strokeWidth = STROKE_WIDTH[resolvedSize]; const a11yProps = { accessible: true, accessibilityRole: "progressbar" as const, accessibilityState: { busy: true }, accessibilityLabel: resolvedLabel, }; if (reducedMotion) { return ( ); } return ( ); } interface A11yProps { readonly accessible: boolean; readonly accessibilityRole: "progressbar"; readonly accessibilityState: { readonly busy: boolean }; readonly accessibilityLabel: string; } interface RingProps { readonly pixelSize: number; readonly strokeWidth: number; readonly style?: StyleProp; readonly testID: string; readonly a11yProps: A11yProps; } interface FullMotionRingProps extends RingProps { readonly size: ResolvedSize; readonly arcColor: string; readonly trackColor: string; } function FullMotionRing({ size, pixelSize, strokeWidth, arcColor, trackColor, style, testID, a11yProps, }: FullMotionRingProps) { const motion = MOTION_BY_SIZE[size]; // Layer 1 — outer linear rotation. Continuous, constant speed. const outerRotate = useSharedValue(0); // Layer 2 — inner 8-phase rotation. Eight 135° eased segments per cycle // (totalling 1080° over `timing-indicator--cycle`) reproduce the // canonical Material Web "8-phase" rhythm. Only when motion.layers === 3. const innerRotate = useSharedValue(0); // Layer 3 — arc length and dash offset. Drives the visible arc growing // from ~1 viewBox-unit to ~90 viewBox-units and sliding around the ring. // Only when motion.layers === 3; fixed-arc sizes use motion.dasharray. const arcProgress = useSharedValue(0); useEffect(() => { outerRotate.value = withRepeat( withTiming(360, { duration: motion.linearRotateMs, easing: Easing.linear, }), -1, ); if (motion.layers === 1) { return; } const phaseDuration = tokens["timing-indicator--cycle"] / 8; const phaseEasing = Easing.bezier(0.4, 0, 0.2, 1); innerRotate.value = withRepeat( withSequence( withTiming(135, { duration: phaseDuration, easing: phaseEasing }), withTiming(270, { duration: phaseDuration, easing: phaseEasing }), withTiming(405, { duration: phaseDuration, easing: phaseEasing }), withTiming(540, { duration: phaseDuration, easing: phaseEasing }), withTiming(675, { duration: phaseDuration, easing: phaseEasing }), withTiming(810, { duration: phaseDuration, easing: phaseEasing }), withTiming(945, { duration: phaseDuration, easing: phaseEasing }), withTiming(1080, { duration: phaseDuration, easing: phaseEasing }), ), -1, ); arcProgress.value = withRepeat( withTiming(1, { duration: tokens["timing-indicator--arc"], easing: Easing.bezier(0.4, 0, 0.2, 1), }), -1, ); // We intentionally do not include the shared values in the dependency // array — they are stable references created by useSharedValue. }, [motion]); const outerStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${outerRotate.value}deg` }], })); const innerStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${innerRotate.value}deg` }], })); const arcAnimatedProps = useAnimatedProps(() => { "worklet"; // Match web's indicatorExpandArc keyframe: // 0% → dasharray (1, 200), offset 0 // 50% → dasharray (90, 200), offset -35 // 100% → dasharray (90, 200), offset -125 // // Phase A (0 → 0.5): expand the visible arc from 1 → 90 while // sliding it from 0 → -35. // Phase B (0.5 → 1): hold the visible arc at 90 while sliding from // -35 → -125. const p = arcProgress.value; let visible: number; let offset: number; if (p < 0.5) { const phase = p * 2; visible = 1 + (90 - 1) * phase; offset = -35 * phase; } else { const phase = (p - 0.5) * 2; visible = 90; offset = -35 + -90 * phase; } return { strokeDasharray: `${visible}, ${GAP}`, strokeDashoffset: offset, }; }); const track = ( ); const ring = motion.layers === 1 ? ( // Fixed-length arc, Layer 1 rotation only — no Layer 2 wrapper. {track} ) : ( {track} ); return ( {ring} ); } interface ReducedMotionRingProps extends RingProps { readonly ringColor: string; } function ReducedMotionRing({ pixelSize, strokeWidth, ringColor, style, testID, a11yProps, }: ReducedMotionRingProps) { // Reduced motion: hide rotation entirely, render one static ring in the // active foreground colour, and pulse opacity 1.0 ↔ 0.35 over a 4s // ease-in-out alternate cycle (2s down, 2s up). Matches web's // reduced-motion fallback exactly. // // CRITICAL: Reanimated 3 automatically suppresses `withTiming` / // `withRepeat` when the OS reduce-motion setting is enabled, snapping // to the target value instantly. That is the right default for most // animations — but the pulse IS our reduce-motion fallback. We // explicitly opt OUT of the suppression via `reduceMotion: // ReduceMotion.Never` on each `withTiming`, so the pulse runs whenever // this branch renders (which only happens when reduce-motion is on). // // We also use `withSequence(down, up)` rather than // `withRepeat(down, -1, true)` because the `reverse` flag on // `withRepeat` is observed to be honored inconsistently on // react-native-reanimated's web target. `withSequence` is equivalent // in observable behaviour and works on both web and native. const pulse = useSharedValue(1); useEffect(() => { pulse.value = withRepeat( withSequence( withTiming(0.35, { duration: 2000, easing: Easing.inOut(Easing.ease), reduceMotion: ReduceMotion.Never, }), withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease), reduceMotion: ReduceMotion.Never, }), ), -1, false, undefined, ReduceMotion.Never, ); }, []); const animatedStyle = useAnimatedStyle(() => ({ opacity: pulse.value, })); return ( ); }