react-native-ai-animations 0.0.0-stage → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -2
  3. package/lib/module/brand/index.js +4 -0
  4. package/lib/module/brand/index.js.map +1 -0
  5. package/lib/module/brand/spinning-mark.js +96 -0
  6. package/lib/module/brand/spinning-mark.js.map +1 -0
  7. package/lib/module/chat/effort-bar.js +853 -0
  8. package/lib/module/chat/effort-bar.js.map +1 -0
  9. package/lib/module/chat/index.js +8 -0
  10. package/lib/module/chat/index.js.map +1 -0
  11. package/lib/module/chat/question-card.js +720 -0
  12. package/lib/module/chat/question-card.js.map +1 -0
  13. package/lib/module/chat/reasoning.js +38 -0
  14. package/lib/module/chat/reasoning.js.map +1 -0
  15. package/lib/module/chat/status-row.js +140 -0
  16. package/lib/module/chat/status-row.js.map +1 -0
  17. package/lib/module/chat/thinking-block.js +255 -0
  18. package/lib/module/chat/thinking-block.js.map +1 -0
  19. package/lib/module/index.js +5 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/internal/color.js +15 -0
  22. package/lib/module/internal/color.js.map +1 -0
  23. package/lib/module/internal/use-elapsed.js +20 -0
  24. package/lib/module/internal/use-elapsed.js.map +1 -0
  25. package/lib/module/package.json +1 -0
  26. package/lib/module/skia/dot-orb.js +256 -0
  27. package/lib/module/skia/dot-orb.js.map +1 -0
  28. package/lib/module/skia/index.js +8 -0
  29. package/lib/module/skia/index.js.map +1 -0
  30. package/lib/module/skia/liquid-orb.js +303 -0
  31. package/lib/module/skia/liquid-orb.js.map +1 -0
  32. package/lib/module/skia/matrix-orb.js +272 -0
  33. package/lib/module/skia/matrix-orb.js.map +1 -0
  34. package/lib/module/skia/media-generating.js +524 -0
  35. package/lib/module/skia/media-generating.js.map +1 -0
  36. package/lib/module/skia/voice-wave.js +278 -0
  37. package/lib/module/skia/voice-wave.js.map +1 -0
  38. package/lib/module/text/index.js +6 -0
  39. package/lib/module/text/index.js.map +1 -0
  40. package/lib/module/text/shimmer-text.js +122 -0
  41. package/lib/module/text/shimmer-text.js.map +1 -0
  42. package/lib/module/text/spoken-text.js +85 -0
  43. package/lib/module/text/spoken-text.js.map +1 -0
  44. package/lib/module/text/streaming-text.js +135 -0
  45. package/lib/module/text/streaming-text.js.map +1 -0
  46. package/lib/module/theme/index.js +5 -0
  47. package/lib/module/theme/index.js.map +1 -0
  48. package/lib/module/theme/palette.js +45 -0
  49. package/lib/module/theme/palette.js.map +1 -0
  50. package/lib/module/theme/provider.js +65 -0
  51. package/lib/module/theme/provider.js.map +1 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/brand/index.d.ts +2 -0
  54. package/lib/typescript/src/brand/index.d.ts.map +1 -0
  55. package/lib/typescript/src/brand/spinning-mark.d.ts +21 -0
  56. package/lib/typescript/src/brand/spinning-mark.d.ts.map +1 -0
  57. package/lib/typescript/src/chat/effort-bar.d.ts +42 -0
  58. package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -0
  59. package/lib/typescript/src/chat/index.d.ts +6 -0
  60. package/lib/typescript/src/chat/index.d.ts.map +1 -0
  61. package/lib/typescript/src/chat/question-card.d.ts +73 -0
  62. package/lib/typescript/src/chat/question-card.d.ts.map +1 -0
  63. package/lib/typescript/src/chat/reasoning.d.ts +20 -0
  64. package/lib/typescript/src/chat/reasoning.d.ts.map +1 -0
  65. package/lib/typescript/src/chat/status-row.d.ts +20 -0
  66. package/lib/typescript/src/chat/status-row.d.ts.map +1 -0
  67. package/lib/typescript/src/chat/thinking-block.d.ts +35 -0
  68. package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -0
  69. package/lib/typescript/src/index.d.ts +3 -0
  70. package/lib/typescript/src/index.d.ts.map +1 -0
  71. package/lib/typescript/src/internal/color.d.ts +2 -0
  72. package/lib/typescript/src/internal/color.d.ts.map +1 -0
  73. package/lib/typescript/src/internal/use-elapsed.d.ts +3 -0
  74. package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -0
  75. package/lib/typescript/src/skia/dot-orb.d.ts +16 -0
  76. package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -0
  77. package/lib/typescript/src/skia/index.d.ts +6 -0
  78. package/lib/typescript/src/skia/index.d.ts.map +1 -0
  79. package/lib/typescript/src/skia/liquid-orb.d.ts +20 -0
  80. package/lib/typescript/src/skia/liquid-orb.d.ts.map +1 -0
  81. package/lib/typescript/src/skia/matrix-orb.d.ts +12 -0
  82. package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -0
  83. package/lib/typescript/src/skia/media-generating.d.ts +59 -0
  84. package/lib/typescript/src/skia/media-generating.d.ts.map +1 -0
  85. package/lib/typescript/src/skia/voice-wave.d.ts +17 -0
  86. package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -0
  87. package/lib/typescript/src/text/index.d.ts +4 -0
  88. package/lib/typescript/src/text/index.d.ts.map +1 -0
  89. package/lib/typescript/src/text/shimmer-text.d.ts +13 -0
  90. package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -0
  91. package/lib/typescript/src/text/spoken-text.d.ts +15 -0
  92. package/lib/typescript/src/text/spoken-text.d.ts.map +1 -0
  93. package/lib/typescript/src/text/streaming-text.d.ts +16 -0
  94. package/lib/typescript/src/text/streaming-text.d.ts.map +1 -0
  95. package/lib/typescript/src/theme/index.d.ts +3 -0
  96. package/lib/typescript/src/theme/index.d.ts.map +1 -0
  97. package/lib/typescript/src/theme/palette.d.ts +33 -0
  98. package/lib/typescript/src/theme/palette.d.ts.map +1 -0
  99. package/lib/typescript/src/theme/provider.d.ts +13 -0
  100. package/lib/typescript/src/theme/provider.d.ts.map +1 -0
  101. package/package.json +133 -4
  102. package/src/brand/index.ts +5 -0
  103. package/src/brand/spinning-mark.tsx +118 -0
  104. package/src/chat/effort-bar.tsx +748 -0
  105. package/src/chat/index.ts +45 -0
  106. package/src/chat/question-card.tsx +649 -0
  107. package/src/chat/reasoning.ts +30 -0
  108. package/src/chat/status-row.tsx +127 -0
  109. package/src/chat/thinking-block.tsx +250 -0
  110. package/src/index.ts +2 -0
  111. package/src/internal/color.ts +18 -0
  112. package/src/internal/use-elapsed.ts +21 -0
  113. package/src/skia/dot-orb.tsx +248 -0
  114. package/src/skia/index.ts +34 -0
  115. package/src/skia/liquid-orb.tsx +274 -0
  116. package/src/skia/matrix-orb.tsx +337 -0
  117. package/src/skia/media-generating.tsx +389 -0
  118. package/src/skia/voice-wave.tsx +215 -0
  119. package/src/text/index.ts +3 -0
  120. package/src/text/shimmer-text.tsx +140 -0
  121. package/src/text/spoken-text.tsx +82 -0
  122. package/src/text/streaming-text.tsx +144 -0
  123. package/src/theme/index.ts +11 -0
  124. package/src/theme/palette.ts +62 -0
  125. package/src/theme/provider.tsx +54 -0
@@ -0,0 +1,748 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import {
3
+ type AccessibilityActionEvent,
4
+ type LayoutChangeEvent,
5
+ PanResponder,
6
+ Text,
7
+ View,
8
+ type StyleProp,
9
+ type TextStyle,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import Animated, {
13
+ Easing,
14
+ FadeIn,
15
+ type SharedValue,
16
+ interpolateColor,
17
+ useAnimatedStyle,
18
+ useDerivedValue,
19
+ useReducedMotion,
20
+ useSharedValue,
21
+ withSpring,
22
+ } from "react-native-reanimated";
23
+ import { withAlpha } from "../internal/color";
24
+ import { type MotionPalette } from "../theme/palette";
25
+ import { useMotionPalette } from "../theme/provider";
26
+
27
+ const LEVEL_SPRING = { damping: 20, stiffness: 190, mass: 0.9 };
28
+ const GRAB_SPRING = { damping: 22, stiffness: 320, mass: 0.7 };
29
+ const EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);
30
+ const DEFAULT_SEGMENT_STYLE: TextStyle = { fontSize: 13, lineHeight: 18, fontWeight: "600" };
31
+ const DEFAULT_GRADIENT = ["#53C26B", "#9BCB4A", "#F0C64A", "#F19A3E", "#EC6A4F", "#E5486F"];
32
+ const SLIDER_TRACK = 40;
33
+ const SLIDER_RIM = 3;
34
+ const SLIDER_STRETCH = 1.4;
35
+ const SLIDER_TAB = 28;
36
+ const SLIDER_TAB_RADIUS = 10;
37
+ const SLIDER_SHOULDER = 8;
38
+ const COMPACT_SLIDER_KNOB = 10;
39
+
40
+ export type EffortBarVariant = "bars" | "track" | "segmented" | "slider";
41
+
42
+ export type EffortLevel = string | { key?: string; label: string; description?: string; value?: string; budget?: number };
43
+
44
+ export type EffortSelection = { index: number; label: string; value?: string; budget?: number };
45
+
46
+ export type EffortBarProps = {
47
+ levels: EffortLevel[];
48
+ value: number;
49
+ onChange?: (index: number, level: EffortSelection) => void;
50
+ variant?: EffortBarVariant;
51
+ size?: "regular" | "compact";
52
+ steps?: number;
53
+ tabLabel?: string;
54
+ levelIcon?: (index: number, color: string) => ReactNode;
55
+ disabled?: boolean;
56
+ fillColors?: string[];
57
+ barHeight?: number;
58
+ onHaptic?: (index: number) => void;
59
+ accessibilityLabel?: string;
60
+ segmentStyle?: StyleProp<TextStyle>;
61
+ colors?: Partial<MotionPalette>;
62
+ style?: StyleProp<ViewStyle>;
63
+ };
64
+
65
+ function normalize(level: EffortLevel): { key?: string; label: string; description?: string; value?: string; budget?: number } {
66
+ return typeof level === "string" ? { label: level } : level;
67
+ }
68
+
69
+ function mix(a: string, b: string, t: number) {
70
+ const parse = (hex: string) => {
71
+ const clean = hex.replace("#", "");
72
+ return [0, 2, 4].map((offset) => parseInt(clean.slice(offset, offset + 2), 16));
73
+ };
74
+ const from = parse(a);
75
+ const to = parse(b);
76
+ const channel = (index: number) => Math.round(from[index] + (to[index] - from[index]) * t).toString(16).padStart(2, "0");
77
+ return `#${channel(0)}${channel(1)}${channel(2)}`;
78
+ }
79
+
80
+ function spread(stops: string[], count: number) {
81
+ if (count <= 1) return [stops[stops.length - 1]];
82
+ if (stops.length === 1) return new Array(count).fill(stops[0]);
83
+ return Array.from({ length: count }, (_, index) => {
84
+ const position = (index / (count - 1)) * (stops.length - 1);
85
+ const low = Math.floor(position);
86
+ const high = Math.min(stops.length - 1, low + 1);
87
+ const a = stops[low];
88
+ const b = stops[high];
89
+ return a.startsWith("#") && b.startsWith("#") ? mix(a, b, position - low) : position - low < 0.5 ? a : b;
90
+ });
91
+ }
92
+
93
+ function filledFor(index: number, count: number, steps: number) {
94
+ return Math.max(1, Math.round(((index + 1) / count) * steps));
95
+ }
96
+
97
+ export function EffortBar({
98
+ levels,
99
+ value,
100
+ onChange,
101
+ variant = "bars",
102
+ size = "regular",
103
+ steps: stepsProp,
104
+ tabLabel,
105
+ levelIcon,
106
+ disabled = false,
107
+ fillColors,
108
+ barHeight,
109
+ onHaptic,
110
+ accessibilityLabel,
111
+ segmentStyle = DEFAULT_SEGMENT_STYLE,
112
+ colors,
113
+ style,
114
+ }: EffortBarProps) {
115
+ const palette = useMotionPalette(colors);
116
+ const reduceMotion = useReducedMotion();
117
+ const items = useMemo(() => levels.map(normalize), [levels]);
118
+ const count = Math.max(1, items.length);
119
+ const index = Math.min(count - 1, Math.max(0, value));
120
+ const current = items[index];
121
+ const compact = size === "compact";
122
+ const stops = fillColors && fillColors.length > 0 ? fillColors : undefined;
123
+ const custom = !!stops;
124
+ const steps = variant === "bars" ? Math.max(count, stepsProp ?? (compact ? count : Math.max(count, 10))) : count;
125
+ const filled = filledFor(index, count, steps);
126
+ const ratio = count > 1 ? index / (count - 1) : 0;
127
+ const fill = useSharedValue(filled);
128
+ const level = useSharedValue(index);
129
+ const knob = useSharedValue(ratio);
130
+ const pressed = useSharedValue(0);
131
+ const [width, setWidth] = useState(0);
132
+ const lastIndex = useRef(index);
133
+ const indexRef = useRef(index);
134
+ const dragging = useRef(false);
135
+ const interactive = !!onChange && !disabled;
136
+ indexRef.current = index;
137
+
138
+ useEffect(() => {
139
+ fill.value = reduceMotion ? filled : withSpring(filled, LEVEL_SPRING);
140
+ level.value = reduceMotion ? index : withSpring(index, LEVEL_SPRING);
141
+ }, [fill, filled, index, level, reduceMotion]);
142
+
143
+ useEffect(() => {
144
+ if (dragging.current) return;
145
+ knob.value = reduceMotion ? ratio : withSpring(ratio, LEVEL_SPRING);
146
+ }, [knob, ratio, reduceMotion]);
147
+
148
+ const barColors = useMemo(() => spread(stops ?? DEFAULT_GRADIENT, steps), [steps, stops]);
149
+ const trackFills = useMemo(
150
+ () => (stops ? spread(stops, count) : items.map(() => palette.text)),
151
+ [count, items, palette.text, stops],
152
+ );
153
+ const sliderTrack = barHeight ?? SLIDER_TRACK;
154
+ const knobSize = compact ? COMPACT_SLIDER_KNOB : sliderTrack;
155
+ const knobInset = compact ? 0 : SLIDER_RIM;
156
+
157
+ const select = (next: number) => {
158
+ const item = items[next];
159
+ onHaptic?.(next);
160
+ onChange?.(next, { index: next, label: item.label, value: item.value, budget: item.budget });
161
+ };
162
+
163
+ const pick = (x: number, grant: boolean) => {
164
+ if (!interactive || width === 0) return;
165
+ let next: number;
166
+ if (variant === "slider") {
167
+ const position = Math.min(1, Math.max(0, (x - knobInset - knobSize / 2) / Math.max(1, width - knobInset * 2 - knobSize)));
168
+ dragging.current = true;
169
+ knob.value = reduceMotion || !grant ? position : withSpring(position, GRAB_SPRING);
170
+ if (grant && !reduceMotion) pressed.value = withSpring(1, GRAB_SPRING);
171
+ next = Math.round(position * (count - 1));
172
+ } else {
173
+ next = Math.min(count - 1, Math.max(0, Math.floor((x / width) * count)));
174
+ }
175
+ if (next === lastIndex.current) return;
176
+ lastIndex.current = next;
177
+ select(next);
178
+ };
179
+ const pickRef = useRef(pick);
180
+ pickRef.current = pick;
181
+
182
+ const release = () => {
183
+ if (!dragging.current) return;
184
+ dragging.current = false;
185
+ const target = count > 1 ? indexRef.current / (count - 1) : 0;
186
+ knob.value = reduceMotion ? target : withSpring(target, LEVEL_SPRING);
187
+ pressed.value = reduceMotion ? 0 : withSpring(0, LEVEL_SPRING);
188
+ };
189
+ const releaseRef = useRef(release);
190
+ releaseRef.current = release;
191
+
192
+ useEffect(() => {
193
+ lastIndex.current = index;
194
+ }, [index]);
195
+
196
+ const responder = useMemo(
197
+ () =>
198
+ PanResponder.create({
199
+ onStartShouldSetPanResponder: () => true,
200
+ onMoveShouldSetPanResponder: (_, gesture) => Math.abs(gesture.dx) > 4,
201
+ onPanResponderTerminationRequest: () => false,
202
+ onPanResponderGrant: (event) => pickRef.current(event.nativeEvent.locationX, true),
203
+ onPanResponderMove: (event) => pickRef.current(event.nativeEvent.locationX, false),
204
+ onPanResponderRelease: () => releaseRef.current(),
205
+ onPanResponderTerminate: () => releaseRef.current(),
206
+ }),
207
+ [],
208
+ );
209
+
210
+ const handleLayout = (event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width);
211
+
212
+ const handleAction = (event: AccessibilityActionEvent) => {
213
+ if (!interactive) return;
214
+ const delta = event.nativeEvent.actionName === "increment" ? 1 : event.nativeEvent.actionName === "decrement" ? -1 : 0;
215
+ const next = Math.min(count - 1, Math.max(0, index + delta));
216
+ if (next !== index) select(next);
217
+ };
218
+
219
+ const height =
220
+ variant === "slider" ? (compact ? knobSize : SLIDER_TAB + sliderTrack) : barHeight ?? (compact ? 14 : variant === "track" ? 44 : variant === "segmented" ? 40 : 56);
221
+ const compactWidth = variant === "segmented" ? count * 12 + 4 : variant === "slider" ? 40 : steps * 5 + (steps - 1) * 2.5;
222
+
223
+ const visual =
224
+ variant === "track" ? (
225
+ <Track items={items} level={level} width={width} height={height} fills={trackFills} palette={palette} compact={compact} segmentStyle={segmentStyle} levelIcon={levelIcon} />
226
+ ) : variant === "segmented" ? (
227
+ <Segmented
228
+ items={items}
229
+ level={level}
230
+ width={width}
231
+ height={height}
232
+ fills={trackFills}
233
+ custom={custom}
234
+ palette={palette}
235
+ compact={compact}
236
+ segmentStyle={segmentStyle}
237
+ levelIcon={levelIcon}
238
+ />
239
+ ) : variant === "slider" ? (
240
+ compact ? (
241
+ <CompactSlider count={count} knob={knob} width={width} knobSize={knobSize} fills={trackFills} palette={palette} />
242
+ ) : (
243
+ <Slider
244
+ count={count}
245
+ label={current?.label ?? ""}
246
+ index={index}
247
+ tabLabel={tabLabel}
248
+ levelIcon={levelIcon}
249
+ knob={knob}
250
+ pressed={pressed}
251
+ width={width}
252
+ track={sliderTrack}
253
+ fills={trackFills}
254
+ palette={palette}
255
+ reduceMotion={reduceMotion}
256
+ segmentStyle={segmentStyle}
257
+ />
258
+ )
259
+ ) : (
260
+ <Bars steps={steps} fill={fill} height={height} colors={barColors} palette={palette} compact={compact} />
261
+ );
262
+
263
+ const control = (
264
+ <View
265
+ onLayout={handleLayout}
266
+ {...(interactive ? responder.panHandlers : {})}
267
+ style={{ height, justifyContent: "flex-end" }}
268
+ >
269
+ <View pointerEvents="none">{visual}</View>
270
+ </View>
271
+ );
272
+
273
+ return (
274
+ <View
275
+ accessible
276
+ accessibilityRole="adjustable"
277
+ accessibilityLabel={accessibilityLabel ?? "Effort"}
278
+ accessibilityValue={{ text: current?.label }}
279
+ accessibilityState={{ disabled }}
280
+ accessibilityActions={interactive ? [{ name: "increment" }, { name: "decrement" }] : undefined}
281
+ onAccessibilityAction={handleAction}
282
+ style={[{ opacity: disabled ? 0.45 : 1 }, compact && { width: compactWidth }, style]}
283
+ >
284
+ {control}
285
+ </View>
286
+ );
287
+ }
288
+
289
+ type BarsProps = {
290
+ steps: number;
291
+ fill: SharedValue<number>;
292
+ height: number;
293
+ colors: string[];
294
+ palette: MotionPalette;
295
+ compact: boolean;
296
+ };
297
+
298
+ function Bars({ steps, fill, height, colors, palette, compact }: BarsProps) {
299
+ const gap = compact ? 2.5 : 6;
300
+ const track = compact ? withAlpha(palette.text, 0.14) : withAlpha(palette.text, 0.08);
301
+ return (
302
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap, height }}>
303
+ {Array.from({ length: steps }, (_, position) => (
304
+ <Bar key={position} position={position} steps={steps} fill={fill} height={height} color={colors[position]} track={track} compact={compact} />
305
+ ))}
306
+ </View>
307
+ );
308
+ }
309
+
310
+ type BarProps = {
311
+ position: number;
312
+ steps: number;
313
+ fill: SharedValue<number>;
314
+ height: number;
315
+ color: string;
316
+ track: string;
317
+ compact: boolean;
318
+ };
319
+
320
+ function Bar({ position, steps, fill, height, color, track, compact }: BarProps) {
321
+ const minimum = compact ? 0.4 : 0.42;
322
+ const ratio = steps > 1 ? minimum + ((1 - minimum) * position) / (steps - 1) : 1;
323
+ const barHeight = Math.round(height * ratio);
324
+ const amount = useDerivedValue(() => Math.min(1, Math.max(0, fill.value - position)));
325
+ const colorStyle = useAnimatedStyle(() => ({ opacity: amount.value }));
326
+ const popStyle = useAnimatedStyle(() => {
327
+ const near = Math.max(0, 1 - Math.abs(fill.value - (position + 1)));
328
+ return { transform: [{ scale: 1 + near * (compact ? 0 : 0.08) }] };
329
+ });
330
+
331
+ return (
332
+ <Animated.View
333
+ style={[
334
+ {
335
+ flex: compact ? undefined : 1,
336
+ width: compact ? 5 : undefined,
337
+ height: barHeight,
338
+ borderRadius: 999,
339
+ backgroundColor: track,
340
+ overflow: "hidden",
341
+ },
342
+ popStyle,
343
+ ]}
344
+ >
345
+ <Animated.View style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: color }, colorStyle]} />
346
+ </Animated.View>
347
+ );
348
+ }
349
+
350
+ type TrackProps = {
351
+ items: { label: string }[];
352
+ level: SharedValue<number>;
353
+ width: number;
354
+ height: number;
355
+ fills: string[];
356
+ palette: MotionPalette;
357
+ compact: boolean;
358
+ segmentStyle: StyleProp<TextStyle>;
359
+ levelIcon?: (index: number, color: string) => ReactNode;
360
+ };
361
+
362
+ function Track({ items, level, width, height, fills, palette, compact, segmentStyle, levelIcon }: TrackProps) {
363
+ const count = items.length;
364
+ const segment = width / Math.max(1, count);
365
+ const radius = height / 2;
366
+ const inputs = items.map((_, position) => position);
367
+ const fillStyle = useAnimatedStyle(() => ({
368
+ width: Math.max(height, segment * (level.value + 1)),
369
+ backgroundColor: count > 1 ? interpolateColor(level.value, inputs, fills) : fills[0],
370
+ }));
371
+ const clipStyle = useAnimatedStyle(() => ({ width: Math.max(height, segment * (level.value + 1)) }));
372
+
373
+ const labels = (color: string) =>
374
+ items.map((item, position) => (
375
+ <View key={`${position}-${item.label}`} style={{ width: segment, alignItems: "center", justifyContent: "center" }}>
376
+ {levelIcon ? (
377
+ levelIcon(position, color)
378
+ ) : (
379
+ <Text numberOfLines={1} style={[segmentStyle, { color }]}>
380
+ {item.label}
381
+ </Text>
382
+ )}
383
+ </View>
384
+ ));
385
+
386
+ return (
387
+ <View style={{ height, borderRadius: radius, borderCurve: "continuous", backgroundColor: palette.surface, overflow: "hidden" }}>
388
+ {!compact
389
+ ? items.slice(1).map((_, position) => (
390
+ <View
391
+ key={position}
392
+ style={{ position: "absolute", top: height * 0.3, bottom: height * 0.3, left: segment * (position + 1), width: 1, backgroundColor: withAlpha(palette.text, 0.1) }}
393
+ />
394
+ ))
395
+ : null}
396
+ {!compact && width > 0 ? (
397
+ <View style={{ position: "absolute", top: 0, left: 0, bottom: 0, flexDirection: "row" }}>{labels(palette.textSecondary)}</View>
398
+ ) : null}
399
+ <Animated.View style={[{ position: "absolute", top: 0, left: 0, bottom: 0, borderRadius: radius, borderCurve: "continuous" }, fillStyle]} />
400
+ {!compact && width > 0 ? (
401
+ <Animated.View style={[{ position: "absolute", top: 0, left: 0, bottom: 0, overflow: "hidden" }, clipStyle]}>
402
+ <View style={{ flexDirection: "row", height, width }}>{labels(palette.background)}</View>
403
+ </Animated.View>
404
+ ) : null}
405
+ </View>
406
+ );
407
+ }
408
+
409
+ type LevelLabelProps = {
410
+ label: string;
411
+ position: number;
412
+ level: SharedValue<number>;
413
+ on: string;
414
+ off: string;
415
+ segmentStyle: StyleProp<TextStyle>;
416
+ align?: "left" | "center" | "right";
417
+ style?: StyleProp<ViewStyle>;
418
+ };
419
+
420
+ function LevelLabel({ label, position, level, on, off, segmentStyle, align = "center", style }: LevelLabelProps) {
421
+ const colorStyle = useAnimatedStyle(() => ({
422
+ color: interpolateColor(Math.min(1, Math.abs(level.value - position)), [0, 1], [on, off]),
423
+ }));
424
+ return (
425
+ <View style={style}>
426
+ <Animated.Text numberOfLines={1} style={[segmentStyle, { textAlign: align }, colorStyle]}>
427
+ {label}
428
+ </Animated.Text>
429
+ </View>
430
+ );
431
+ }
432
+
433
+ function LevelGlyph({
434
+ position,
435
+ level,
436
+ render,
437
+ on,
438
+ off,
439
+ }: {
440
+ position: number;
441
+ level: SharedValue<number>;
442
+ render: (index: number, color: string) => ReactNode;
443
+ on: string;
444
+ off: string;
445
+ }) {
446
+ const onStyle = useAnimatedStyle(() => ({ opacity: 1 - Math.min(1, Math.abs(level.value - position)) }));
447
+ return (
448
+ <View style={{ alignItems: "center", justifyContent: "center" }}>
449
+ {render(position, off)}
450
+ <Animated.View style={[{ position: "absolute" }, onStyle]}>{render(position, on)}</Animated.View>
451
+ </View>
452
+ );
453
+ }
454
+
455
+ type SegmentedProps = {
456
+ items: { label: string }[];
457
+ level: SharedValue<number>;
458
+ width: number;
459
+ height: number;
460
+ fills: string[];
461
+ custom: boolean;
462
+ palette: MotionPalette;
463
+ compact: boolean;
464
+ segmentStyle: StyleProp<TextStyle>;
465
+ levelIcon?: (index: number, color: string) => ReactNode;
466
+ };
467
+
468
+ function Segmented({ items, level, width, height, fills, custom, palette, compact, segmentStyle, levelIcon }: SegmentedProps) {
469
+ const count = items.length;
470
+ const inset = compact ? 2 : 3;
471
+ const radius = height / 2;
472
+ const segment = Math.max(0, (width - inset * 2) / Math.max(1, count));
473
+ const inputs = items.map((_, position) => position);
474
+ const thumbStyle = useAnimatedStyle(() => ({
475
+ transform: [{ translateX: segment * level.value }],
476
+ backgroundColor: custom ? (count > 1 ? interpolateColor(level.value, inputs, fills) : fills[0]) : compact ? palette.text : palette.card,
477
+ }));
478
+
479
+ return (
480
+ <View style={{ height, flexDirection: "row", padding: inset, borderRadius: radius, borderCurve: "continuous", backgroundColor: palette.surface }}>
481
+ {width > 0 ? (
482
+ <Animated.View
483
+ style={[
484
+ { position: "absolute", top: inset, left: inset, bottom: inset, width: segment, borderRadius: radius - inset, borderCurve: "continuous" },
485
+ !custom && !compact ? { shadowColor: "#000000", shadowOpacity: 0.1, shadowRadius: 6, shadowOffset: { width: 0, height: 2 } } : null,
486
+ thumbStyle,
487
+ ]}
488
+ />
489
+ ) : null}
490
+ {!compact && levelIcon
491
+ ? items.map((item, position) => (
492
+ <View key={`${position}-${item.label}`} style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
493
+ <LevelGlyph position={position} level={level} render={levelIcon} on={custom ? palette.background : palette.text} off={palette.textSecondary} />
494
+ </View>
495
+ ))
496
+ : null}
497
+ {!compact && !levelIcon
498
+ ? items.map((item, position) => (
499
+ <LevelLabel
500
+ key={`${position}-${item.label}`}
501
+ label={item.label}
502
+ position={position}
503
+ level={level}
504
+ on={custom ? palette.background : palette.text}
505
+ off={palette.textSecondary}
506
+ segmentStyle={segmentStyle}
507
+ style={{ flex: 1, alignItems: "center", justifyContent: "center" }}
508
+ />
509
+ ))
510
+ : null}
511
+ </View>
512
+ );
513
+ }
514
+
515
+ type SliderProps = {
516
+ count: number;
517
+ label: string;
518
+ index: number;
519
+ tabLabel?: string;
520
+ levelIcon?: (index: number, color: string) => ReactNode;
521
+ knob: SharedValue<number>;
522
+ pressed: SharedValue<number>;
523
+ width: number;
524
+ track: number;
525
+ fills: string[];
526
+ palette: MotionPalette;
527
+ reduceMotion: boolean;
528
+ segmentStyle: StyleProp<TextStyle>;
529
+ };
530
+
531
+ function Slider({ count, label, index, tabLabel, levelIcon, knob, pressed, width, track, fills, palette, reduceMotion, segmentStyle }: SliderProps) {
532
+ const span = Math.max(0, width - SLIDER_RIM * 2 - track);
533
+ const radius = track / 2;
534
+ const edge = radius;
535
+ const tabWidth = useSharedValue(0);
536
+ const inputs = Array.from({ length: count }, (_, position) => position);
537
+ const knobFrame = useDerivedValue(() => {
538
+ const size = track * (1 + pressed.value * (SLIDER_STRETCH - 1));
539
+ const center = SLIDER_RIM + track / 2 + knob.value * span;
540
+ const left = Math.min(Math.max(center - size / 2, SLIDER_RIM), Math.max(SLIDER_RIM, width - SLIDER_RIM - size));
541
+ return { left, size, center };
542
+ });
543
+ const fillStyle = useAnimatedStyle(() => ({
544
+ width: knobFrame.value.left + knobFrame.value.size + SLIDER_RIM,
545
+ backgroundColor: count > 1 ? interpolateColor(knob.value * (count - 1), inputs, fills) : fills[0],
546
+ }));
547
+ const knobStyle = useAnimatedStyle(() => ({ width: knobFrame.value.size, transform: [{ translateX: knobFrame.value.left }] }));
548
+ const tabStyle = useAnimatedStyle(() => {
549
+ const wanted = knobFrame.value.center - tabWidth.value / 2;
550
+ const left = Math.min(Math.max(wanted, edge), Math.max(edge, width - edge - tabWidth.value));
551
+ return { opacity: tabWidth.value > 0 ? 1 : 0, transform: [{ translateX: left }] };
552
+ });
553
+
554
+ if (width === 0) return <View style={{ height: SLIDER_TAB + track }} />;
555
+
556
+ return (
557
+ <View style={{ height: SLIDER_TAB + track }}>
558
+ <Animated.View
559
+ onLayout={(event) => {
560
+ tabWidth.value = event.nativeEvent.layout.width;
561
+ }}
562
+ style={[{ position: "absolute", top: 0, left: 0 }, tabStyle]}
563
+ >
564
+ <View
565
+ style={{
566
+ height: SLIDER_TAB + 1,
567
+ paddingHorizontal: 12,
568
+ paddingBottom: 1,
569
+ flexDirection: "row",
570
+ alignItems: "center",
571
+ gap: 5,
572
+ borderTopLeftRadius: SLIDER_TAB_RADIUS,
573
+ borderTopRightRadius: SLIDER_TAB_RADIUS,
574
+ borderCurve: "continuous",
575
+ backgroundColor: palette.surface,
576
+ }}
577
+ >
578
+ {tabLabel ? <Text style={[segmentStyle, { color: palette.text, fontWeight: "700" }]}>{tabLabel}</Text> : null}
579
+ {levelIcon ? (
580
+ <Animated.View key={index} entering={reduceMotion ? undefined : FadeIn.duration(200).easing(EASE_OUT)}>
581
+ {levelIcon(index, palette.textSecondary)}
582
+ </Animated.View>
583
+ ) : (
584
+ <Animated.Text
585
+ key={index}
586
+ entering={reduceMotion ? undefined : FadeIn.duration(200).easing(EASE_OUT)}
587
+ style={[segmentStyle, { color: palette.textSecondary, fontWeight: "500" }]}
588
+ >
589
+ {label}
590
+ </Animated.Text>
591
+ )}
592
+ </View>
593
+ <Shoulder side="left" color={palette.surface} />
594
+ <Shoulder side="right" color={palette.surface} />
595
+ </Animated.View>
596
+ <View
597
+ style={{
598
+ position: "absolute",
599
+ top: SLIDER_TAB,
600
+ left: 0,
601
+ right: 0,
602
+ height: track,
603
+ borderRadius: radius,
604
+ borderCurve: "continuous",
605
+ backgroundColor: palette.surface,
606
+ overflow: "hidden",
607
+ }}
608
+ >
609
+ <Animated.View style={[{ position: "absolute", top: 0, left: 0, bottom: 0, borderRadius: radius, borderCurve: "continuous" }, fillStyle]} />
610
+ {inputs.map((position) => (
611
+ <Stop
612
+ key={position}
613
+ position={position}
614
+ count={count}
615
+ knob={knob}
616
+ left={SLIDER_RIM + track / 2 + (count > 1 ? (span * position) / (count - 1) : 0)}
617
+ size={Math.round(track * 0.3)}
618
+ on={withAlpha(palette.card, 0.55)}
619
+ off={withAlpha(palette.text, 0.18)}
620
+ />
621
+ ))}
622
+ <Animated.View
623
+ style={[
624
+ {
625
+ position: "absolute",
626
+ top: SLIDER_RIM,
627
+ left: 0,
628
+ height: track - SLIDER_RIM * 2,
629
+ borderRadius: radius - SLIDER_RIM,
630
+ borderCurve: "continuous",
631
+ backgroundColor: palette.card,
632
+ shadowColor: "#000000",
633
+ shadowOpacity: 0.12,
634
+ shadowRadius: 4,
635
+ shadowOffset: { width: 0, height: 1 },
636
+ },
637
+ knobStyle,
638
+ ]}
639
+ />
640
+ </View>
641
+ </View>
642
+ );
643
+ }
644
+
645
+ function Shoulder({ side, color }: { side: "left" | "right"; color: string }) {
646
+ const size = SLIDER_SHOULDER;
647
+ return (
648
+ <View style={[{ position: "absolute", bottom: 0, width: size, height: size, overflow: "hidden" }, side === "left" ? { left: -size } : { right: -size }]}>
649
+ <View
650
+ style={{
651
+ position: "absolute",
652
+ top: -size * 2,
653
+ left: side === "left" ? -size * 2 : -size,
654
+ width: size * 4,
655
+ height: size * 4,
656
+ borderRadius: size * 2,
657
+ borderWidth: size,
658
+ borderColor: color,
659
+ }}
660
+ />
661
+ </View>
662
+ );
663
+ }
664
+
665
+ type CompactSliderProps = {
666
+ count: number;
667
+ knob: SharedValue<number>;
668
+ width: number;
669
+ knobSize: number;
670
+ fills: string[];
671
+ palette: MotionPalette;
672
+ };
673
+
674
+ function CompactSlider({ count, knob, width, knobSize, fills, palette }: CompactSliderProps) {
675
+ const inset = knobSize / 2;
676
+ const span = Math.max(0, width - knobSize);
677
+ const inputs = Array.from({ length: count }, (_, position) => position);
678
+ const fillStyle = useAnimatedStyle(() => ({
679
+ width: inset + span * knob.value,
680
+ backgroundColor: count > 1 ? interpolateColor(knob.value * (count - 1), inputs, fills) : fills[0],
681
+ }));
682
+ const knobStyle = useAnimatedStyle(() => ({ transform: [{ translateX: span * knob.value }] }));
683
+
684
+ return (
685
+ <View style={{ height: knobSize, justifyContent: "center" }}>
686
+ <View style={{ height: 4, borderRadius: 2, backgroundColor: palette.surface, overflow: "hidden" }}>
687
+ {width > 0 ? <Animated.View style={[{ position: "absolute", top: 0, left: 0, bottom: 0, borderRadius: 2 }, fillStyle]} /> : null}
688
+ </View>
689
+ {width > 0 ? (
690
+ <Animated.View
691
+ style={[
692
+ {
693
+ position: "absolute",
694
+ top: 0,
695
+ left: 0,
696
+ width: knobSize,
697
+ height: knobSize,
698
+ borderRadius: knobSize / 2,
699
+ backgroundColor: palette.card,
700
+ borderWidth: 1,
701
+ borderColor: withAlpha(palette.text, 0.08),
702
+ shadowColor: "#000000",
703
+ shadowOpacity: 0.08,
704
+ shadowRadius: 2,
705
+ shadowOffset: { width: 0, height: 1 },
706
+ },
707
+ knobStyle,
708
+ ]}
709
+ />
710
+ ) : null}
711
+ </View>
712
+ );
713
+ }
714
+
715
+ type StopProps = {
716
+ position: number;
717
+ count: number;
718
+ knob: SharedValue<number>;
719
+ left: number;
720
+ size: number;
721
+ on: string;
722
+ off: string;
723
+ };
724
+
725
+ function Stop({ position, count, knob, left, size, on, off }: StopProps) {
726
+ const colorStyle = useAnimatedStyle(() => ({
727
+ backgroundColor: interpolateColor(Math.min(1, Math.max(0, knob.value * (count - 1) - position + 0.5)), [0, 1], [off, on]),
728
+ }));
729
+ return <Animated.View style={[{ position: "absolute", left: left - 1, top: "50%", marginTop: -size / 2, width: 2, height: size, borderRadius: 1 }, colorStyle]} />;
730
+ }
731
+
732
+ export type EffortStyleProps = Omit<EffortBarProps, "variant">;
733
+
734
+ export function EffortBars(props: EffortStyleProps) {
735
+ return <EffortBar {...props} variant="bars" />;
736
+ }
737
+
738
+ export function EffortTrack(props: EffortStyleProps) {
739
+ return <EffortBar {...props} variant="track" />;
740
+ }
741
+
742
+ export function EffortSegmented(props: EffortStyleProps) {
743
+ return <EffortBar {...props} variant="segmented" />;
744
+ }
745
+
746
+ export function EffortSlider(props: EffortStyleProps) {
747
+ return <EffortBar {...props} variant="slider" />;
748
+ }