@isoline-ui/react-native 0.0.0-stage → 1.0.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 (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. package/src/utils.ts +7 -0
@@ -0,0 +1,127 @@
1
+ import * as React from 'react';
2
+ import {
3
+ type GestureResponderEvent,
4
+ type PressableProps,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ } from 'react-native';
8
+
9
+ import Animated, {
10
+ useAnimatedStyle,
11
+ useSharedValue,
12
+ withSpring,
13
+ } from 'react-native-reanimated';
14
+
15
+ import { Surface, type MaterialOptions } from '../provider.tsx';
16
+ import { usesPlatformMaterial } from '../material.ts';
17
+ import { Pressable } from './pressable.tsx';
18
+ import { spatial } from '../motion.ts';
19
+ import { useIsolinePlatform, useRipple, useThemeColors } from '../provider.tsx';
20
+ import { cn } from '../utils.ts';
21
+
22
+ export interface IconButtonProps extends MaterialOptions {
23
+ icon: React.ComponentType<{ size?: number; color?: string; fill?: string }>;
24
+ /**
25
+ * Receives the press event, for a button nested inside a pressable row that
26
+ * has to stop the row from seeing the same tap.
27
+ */
28
+ onPress: (event: GestureResponderEvent) => void;
29
+ /** Touch target diameter in dp; also sets the ripple's radius. */
30
+ size?: number;
31
+ iconSize?: number;
32
+ /** Defaults to `onSurfaceVariant`. */
33
+ color?: string;
34
+ fill?: string;
35
+ accessibilityLabel?: string;
36
+ hitSlop?: PressableProps['hitSlop'];
37
+ className?: string;
38
+ /** For a filled/elevated variant — plain RN styles, since `elevation` can't be a class. */
39
+ style?: StyleProp<ViewStyle>;
40
+ disabled?: boolean;
41
+ }
42
+
43
+ /**
44
+ * The one size every icon button in a screen's header uses: a full 48dp
45
+ * target around a 22dp glyph, so a back arrow, a star and a sync toggle read
46
+ * as the same kind of control from one screen to the next. Spread it in —
47
+ * `<IconButton {...HEADER_ICON_BUTTON} … />` — rather than repeating the
48
+ * numbers, which is how they drifted apart before.
49
+ */
50
+ export const HEADER_ICON_BUTTON = { size: 48, iconSize: 22 } as const;
51
+
52
+ /** A circular icon-only pressable with the app's standard borderless ripple. */
53
+ export function IconButton({
54
+ icon: Icon,
55
+ onPress,
56
+ size = 40,
57
+ iconSize = 18,
58
+ color,
59
+ fill,
60
+ accessibilityLabel,
61
+ hitSlop = 8,
62
+ className,
63
+ style,
64
+ disabled,
65
+ material = 'solid',
66
+ glassStyle,
67
+ tintColor,
68
+ fallbackColor,
69
+ interactive = true,
70
+ }: IconButtonProps) {
71
+ const platform = useIsolinePlatform();
72
+ const ios = platform === 'ios';
73
+ const materialActive = usesPlatformMaterial(platform, material);
74
+ const extent = materialActive ? Math.max(size, 44) : size;
75
+ const colors = useThemeColors();
76
+ const ripple = useRipple();
77
+ // The symbol gives a little under the finger, as the ripple spreads round it.
78
+ const pressed = useSharedValue(0);
79
+ const iconStyle = useAnimatedStyle(() => ({
80
+ transform: [{ scale: 1 - pressed.value * 0.14 }],
81
+ }));
82
+
83
+ const control = (
84
+ <Pressable
85
+ onPress={onPress}
86
+ onPressIn={() => {
87
+ pressed.value = withSpring(1, spatial.fast);
88
+ }}
89
+ onPressOut={() => {
90
+ pressed.value = withSpring(0, spatial.default);
91
+ }}
92
+ hitSlop={hitSlop}
93
+ disabled={disabled}
94
+ accessibilityRole="button"
95
+ accessibilityLabel={accessibilityLabel}
96
+ android_ripple={{ color: ripple.onNeutral, borderless: true, radius: size / 2 }}
97
+ className={cn(
98
+ 'items-center justify-center rounded-full',
99
+ disabled && 'opacity-50',
100
+ className,
101
+ )}
102
+ style={[{ width: extent, height: extent }, !materialActive && style]}
103
+ >
104
+ <Animated.View style={iconStyle}>
105
+ <Icon
106
+ size={iconSize}
107
+ color={color ?? (ios ? colors.primary : colors.onSurfaceVariant)}
108
+ {...(fill ? { fill } : null)}
109
+ />
110
+ </Animated.View>
111
+ </Pressable>
112
+ );
113
+ return materialActive ? (
114
+ <Surface
115
+ material={material}
116
+ glassStyle={glassStyle}
117
+ tintColor={tintColor}
118
+ fallbackColor={fallbackColor}
119
+ interactive={interactive}
120
+ style={[{ borderRadius: extent / 2 }, style]}
121
+ >
122
+ {control}
123
+ </Surface>
124
+ ) : (
125
+ control
126
+ );
127
+ }
@@ -0,0 +1,43 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+ import Svg, { Path } from 'react-native-svg';
3
+
4
+ export interface IconProps {
5
+ size?: number;
6
+ color?: string;
7
+ fill?: string;
8
+ ruleColor?: string;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+ /** Small functional glyphs, drawn for Isoline. Consumer icons use IconProps. */
12
+ function glyph(path: string) {
13
+ return function Glyph({ size = 24, color = '#000000', style }: IconProps) {
14
+ return (
15
+ <Svg width={size} height={size} viewBox="0 0 24 24" style={style} accessible={false}>
16
+ <Path
17
+ d={path}
18
+ fill="none"
19
+ stroke={color}
20
+ strokeWidth={2}
21
+ strokeLinecap="round"
22
+ strokeLinejoin="round"
23
+ />
24
+ </Svg>
25
+ );
26
+ };
27
+ }
28
+ export const ArrowLeft = glyph('M20 12H4m7-7-7 7 7 7');
29
+ export const Check = glyph('m5 12 4 4L19 6');
30
+ export const ChevronDown = glyph('m6 9 6 6 6-6');
31
+ export const Search = glyph('M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0');
32
+ export const X = glyph('m6 6 12 12M18 6 6 18');
33
+
34
+ export const ChevronUp = glyph('m6 15 6-6 6 6');
35
+ export const ChevronRight = glyph('m9 6 6 6-6 6');
36
+ export const Eye = glyph(
37
+ 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12 M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
38
+ );
39
+ export const EyeOff = glyph(
40
+ 'm3 3 18 18M9 5.5a12 12 0 0 1 3-.5c6 0 10 7 10 7a20 20 0 0 1-4 4M6 6a21 21 0 0 0-4 6s4 7 10 7a12 12 0 0 0 5-1M10 10a3 3 0 0 0 4 4',
41
+ );
42
+
43
+ export const Plus = glyph('M12 5v14M5 12h14');
@@ -0,0 +1,80 @@
1
+ import * as React from 'react';
2
+ import { TextInput, View } from 'react-native';
3
+
4
+ import { PasswordVisibility } from './password-visibility.tsx';
5
+ import { Text } from './text.tsx';
6
+ import { useThemeColors } from '../provider.tsx';
7
+ import { cn } from '../utils.ts';
8
+
9
+ export type InputProps = React.ComponentProps<typeof TextInput> & {
10
+ label?: string;
11
+ /** Shown below the field in destructive color. */
12
+ error?: string | null;
13
+ hint?: string;
14
+ };
15
+
16
+ export const Input: React.ForwardRefExoticComponent<
17
+ InputProps & React.RefAttributes<React.ElementRef<typeof TextInput>>
18
+ > = React.forwardRef<React.ElementRef<typeof TextInput>, InputProps>(function Input(
19
+ {
20
+ className,
21
+ label,
22
+ error,
23
+ hint,
24
+ accessibilityLabel,
25
+ accessibilityHint,
26
+ secureTextEntry,
27
+ ...props
28
+ },
29
+ ref,
30
+ ) {
31
+ const colors = useThemeColors();
32
+ // A masked field can be shown in the clear on request: a password typed on
33
+ // a phone keyboard is easy to get wrong and impossible to check otherwise.
34
+ const [revealed, setRevealed] = React.useState(false);
35
+
36
+ return (
37
+ <View className="gap-2">
38
+ {label ? <Text variant="label">{label}</Text> : null}
39
+
40
+ <View className="justify-center">
41
+ <TextInput
42
+ ref={ref}
43
+ className={cn(
44
+ 'h-14 rounded-xl border border-input bg-surfaceContainerLowest px-5 text-base text-foreground',
45
+ error && 'border-destructive',
46
+ secureTextEntry && 'pr-14',
47
+ className,
48
+ )}
49
+ // Mail addresses and passwords are the two things people mistype most;
50
+ // never help them along with autocorrect or capitalisation.
51
+ autoCapitalize={props.autoCapitalize ?? 'none'}
52
+ autoCorrect={props.autoCorrect ?? false}
53
+ placeholderTextColor={colors.onSurfaceVariant}
54
+ // The visible label is a sibling Text, not a native <label>, so a screen
55
+ // reader landing on the field itself hears nothing without this — and
56
+ // the error/hint line is otherwise a separate node it may never reach.
57
+ accessibilityLabel={accessibilityLabel ?? label}
58
+ accessibilityHint={accessibilityHint ?? error ?? hint}
59
+ {...props}
60
+ secureTextEntry={secureTextEntry && !revealed}
61
+ />
62
+
63
+ {secureTextEntry ? (
64
+ <PasswordVisibility
65
+ revealed={revealed}
66
+ onPress={() => setRevealed((value) => !value)}
67
+ />
68
+ ) : null}
69
+ </View>
70
+
71
+ {error ? (
72
+ <Text variant="caption" className="text-destructive">
73
+ {error}
74
+ </Text>
75
+ ) : hint ? (
76
+ <Text variant="caption">{hint}</Text>
77
+ ) : null}
78
+ </View>
79
+ );
80
+ });
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+ import { Platform, View, type ViewProps } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+
5
+ import { useKeyboardHeight } from '../use-keyboard-height.ts';
6
+
7
+ /**
8
+ * A view that gives up the part of the screen the on-screen keyboard covers.
9
+ *
10
+ * Edge-to-edge (the default since Expo SDK 54) stops Android's `adjustResize`
11
+ * from shrinking the window, and `KeyboardAvoidingView` cannot be relied on to
12
+ * take over: its `height` mode measures the keyboard against a frame that no
13
+ * longer ends where the keyboard begins, and leaves the layout full height.
14
+ * A scroll view inside it then ends behind the keyboard, so its lower fields
15
+ * can neither be seen nor scrolled to. Padding by the measured keyboard height
16
+ * shrinks the scroll view's viewport instead, which also lets Android scroll a
17
+ * focused field into view.
18
+ */
19
+ export function KeyboardInsetView({ style, ...props }: ViewProps) {
20
+ const insets = useSafeAreaInsets();
21
+ const keyboardHeight = useKeyboardHeight();
22
+ // Android reports the keyboard above its navigation bar. On iOS the
23
+ // keyboard already includes the home-indicator area.
24
+ const paddingBottom =
25
+ keyboardHeight > 0 ? keyboardHeight + (Platform.OS === 'android' ? insets.bottom : 0) : 0;
26
+
27
+ return <View style={[style, { paddingBottom }]} {...props} />;
28
+ }
@@ -0,0 +1,134 @@
1
+ import * as React from 'react';
2
+ import { ScrollView, View, type ScrollViewProps } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { Text } from './text.tsx';
5
+ import { Button } from './button.tsx';
6
+ import { IconButton } from './icon-button.tsx';
7
+ import { BackButton } from './back-button.tsx';
8
+ import { X } from './icons.tsx';
9
+ import type { MaterialOptions } from '../provider.tsx';
10
+
11
+ export interface SelectionHeaderProps {
12
+ count: number;
13
+ countLabel: string;
14
+ allSelected: boolean;
15
+ onClear: () => void;
16
+ onToggleAll: () => void;
17
+ clearLabel: string;
18
+ selectAllLabel: string;
19
+ deselectAllLabel: string;
20
+ }
21
+ export function SelectionHeader({
22
+ count,
23
+ countLabel,
24
+ allSelected,
25
+ onClear,
26
+ onToggleAll,
27
+ clearLabel,
28
+ selectAllLabel,
29
+ deselectAllLabel,
30
+ }: SelectionHeaderProps) {
31
+ return (
32
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
33
+ <IconButton icon={X} accessibilityLabel={clearLabel} onPress={onClear} />
34
+ <Text accessibilityLiveRegion="polite" style={{ flex: 1, fontWeight: '600' }}>
35
+ {countLabel}
36
+ </Text>
37
+ <Button
38
+ size="s"
39
+ variant="ghost"
40
+ label={allSelected ? deselectAllLabel : selectAllLabel}
41
+ onPress={onToggleAll}
42
+ />
43
+ </View>
44
+ );
45
+ }
46
+ export interface ListSectionHeaderProps {
47
+ title: string;
48
+ first?: boolean;
49
+ trailing?: React.ReactNode;
50
+ }
51
+ export function ListSectionHeader({ title, first, trailing }: ListSectionHeaderProps) {
52
+ return (
53
+ <View
54
+ style={{
55
+ flexDirection: 'row',
56
+ alignItems: 'center',
57
+ gap: 8,
58
+ paddingTop: first ? 0 : 16,
59
+ paddingBottom: 4,
60
+ }}
61
+ >
62
+ <Text accessibilityRole="header" variant="label" style={{ flex: 1 }}>
63
+ {title}
64
+ </Text>
65
+ {trailing}
66
+ </View>
67
+ );
68
+ }
69
+ export interface JumpToTopPillProps extends MaterialOptions {
70
+ label: string;
71
+ onPress: () => void;
72
+ count?: number;
73
+ accessibilityLabel?: string;
74
+ }
75
+ export function JumpToTopPill({
76
+ label,
77
+ onPress,
78
+ count = 0,
79
+ accessibilityLabel,
80
+ ...materialOptions
81
+ }: JumpToTopPillProps) {
82
+ return (
83
+ <Button
84
+ {...materialOptions}
85
+ label={label}
86
+ accessibilityLabel={accessibilityLabel ?? label}
87
+ onPress={onPress}
88
+ variant={count > 0 ? 'default' : 'tonal'}
89
+ size="s"
90
+ />
91
+ );
92
+ }
93
+ export interface SettingsScreenLayoutProps {
94
+ title: string;
95
+ caption?: string;
96
+ onBack: () => void;
97
+ backLabel: string;
98
+ children: React.ReactNode;
99
+ overlay?: React.ReactNode;
100
+ scrollProps?: Omit<ScrollViewProps, 'children'>;
101
+ }
102
+ /** Screen geometry without a router dependency. Overlays stay outside the scrolling content. */
103
+ export function SettingsScreenLayout({
104
+ title,
105
+ caption,
106
+ onBack,
107
+ backLabel,
108
+ children,
109
+ overlay,
110
+ scrollProps,
111
+ }: SettingsScreenLayoutProps) {
112
+ const insets = useSafeAreaInsets();
113
+ return (
114
+ <View style={{ flex: 1, paddingTop: insets.top }}>
115
+ <View style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}>
116
+ <BackButton onPress={onBack} label={backLabel} />
117
+ <Text variant="title" accessibilityRole="header">
118
+ {title}
119
+ </Text>
120
+ {caption ? <Text variant="caption">{caption}</Text> : null}
121
+ </View>
122
+ <ScrollView
123
+ {...scrollProps}
124
+ contentContainerStyle={[
125
+ { padding: 16, paddingBottom: insets.bottom + 24, gap: 20 },
126
+ scrollProps?.contentContainerStyle,
127
+ ]}
128
+ >
129
+ {children}
130
+ </ScrollView>
131
+ {overlay}
132
+ </View>
133
+ );
134
+ }
@@ -0,0 +1,183 @@
1
+ import * as React from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import Animated, {
4
+ Easing,
5
+ cancelAnimation,
6
+ useAnimatedProps,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
+ withRepeat,
10
+ withTiming,
11
+ } from 'react-native-reanimated';
12
+ import Svg, { Path } from 'react-native-svg';
13
+
14
+ import { KEEP_MOTION, useReducedMotion } from '../reduced-motion.ts';
15
+ import { useIsolinePlatform, useThemeColors } from '../provider.tsx';
16
+ import { blendRadii, radiiToPath, shapeRadii, type ShapeSpec } from '../shape.ts';
17
+ import { cn } from '../utils.ts';
18
+
19
+ /**
20
+ * The Material 3 Expressive loading indicator.
21
+ *
22
+ * Expressive replaced the indeterminate spinner for waits under about five
23
+ * seconds. Instead of a rotating arc it is a single filled shape that turns
24
+ * while morphing through a sequence of polygons — the morph is the signal that
25
+ * something is happening, so it stays legible at sizes where a thin arc turns
26
+ * into a grey smudge.
27
+ *
28
+ * The outline is rebuilt every frame on the UI thread from interpolated radii
29
+ * (see `src/lib/shape.ts`), which is what lets a four-sided shape become a
30
+ * nine-sided one; morphing between two vertex lists of different lengths is
31
+ * not otherwise defined.
32
+ */
33
+
34
+ /** The shape sequence, chosen so no two neighbours share a corner count. */
35
+ const SEQUENCE: ShapeSpec[] = [
36
+ { vertices: 4, roundness: 0.55, rotation: 0 },
37
+ { vertices: 7, roundness: 0.4, rotation: 0.3 },
38
+ { vertices: 5, roundness: 0.5, rotation: 0.6 },
39
+ { vertices: 9, roundness: 0.35, rotation: 0.1 },
40
+ { vertices: 6, roundness: 0.45, rotation: 0.5 },
41
+ { vertices: 8, roundness: 0.4, rotation: 0.2 },
42
+ ];
43
+
44
+ /** Sampled once at module load; only the blend runs per frame. */
45
+ const RADII = SEQUENCE.map((shape) => shapeRadii(shape));
46
+
47
+ /** Time on each shape. Slow enough to read as a shape, not as a flicker. */
48
+ const MORPH_MS = 650;
49
+ /** One full turn. Deliberately not a multiple of the morph, so it never loops
50
+ * back to an identical frame and start looking like a static image. */
51
+ const SPIN_MS = 4600;
52
+
53
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
54
+
55
+ export interface LoadingIndicatorProps {
56
+ size?: number;
57
+ color?: string;
58
+ /** Draws the M3 "contained" variant — the shape inside a tonal circle. */
59
+ contained?: boolean;
60
+ className?: string;
61
+ }
62
+
63
+ export function LoadingIndicator({
64
+ size = 48,
65
+ color,
66
+ contained = false,
67
+ className,
68
+ }: LoadingIndicatorProps) {
69
+ const ios = useIsolinePlatform() === 'ios';
70
+ const theme = useThemeColors();
71
+ const resolvedColor = color ?? theme.primary;
72
+ const progress = useSharedValue(0);
73
+ const spin = useSharedValue(0);
74
+
75
+ /*
76
+ * A busy indicator keeps moving under "Remove animations", the same way the
77
+ * platform's own does. What the setting is for is motion the reader did not
78
+ * ask for and cannot escape; this is the opposite — it is the answer to
79
+ * "is anything happening", and a spinner frozen mid-turn says the app has
80
+ * hung. The shape-morph is the decorative half of it, and that does stop.
81
+ */
82
+ const reducedMotion = useReducedMotion();
83
+
84
+ React.useEffect(() => {
85
+ if (ios) return;
86
+ progress.value = 0;
87
+ if (!reducedMotion) {
88
+ progress.value = withRepeat(
89
+ // Linear across the whole sequence; the per-step easing is applied in
90
+ // the worklet below, so the loop can restart without a seam.
91
+ withTiming(RADII.length, {
92
+ duration: RADII.length * MORPH_MS,
93
+ easing: Easing.linear,
94
+ }),
95
+ -1,
96
+ );
97
+ }
98
+
99
+ spin.value = 0;
100
+ spin.value = withRepeat(
101
+ withTiming(360, {
102
+ duration: SPIN_MS,
103
+ easing: Easing.linear,
104
+ reduceMotion: KEEP_MOTION,
105
+ }),
106
+ -1,
107
+ );
108
+
109
+ return () => {
110
+ cancelAnimation(progress);
111
+ cancelAnimation(spin);
112
+ };
113
+ }, [ios, progress, reducedMotion, spin]);
114
+
115
+ const inset = contained ? size * 0.24 : 0;
116
+ const canvas = size;
117
+ const radius = (size - inset * 2) / 2;
118
+
119
+ const animatedProps = useAnimatedProps(() => {
120
+ // Reanimated can briefly publish a non-finite or terminal progress value
121
+ // while a repeating animation is being attached/detached. Normalize it
122
+ // before indexing so a transient frame can never pass undefined radii to
123
+ // the UI-thread worklet.
124
+ const value = Number.isFinite(progress.value) ? progress.value : 0;
125
+ const rawIndex = Math.floor(value);
126
+ const index = ((rawIndex % RADII.length) + RADII.length) % RADII.length;
127
+ const next = (index + 1) % RADII.length;
128
+ const from = RADII[index] ?? RADII[0]!;
129
+ const to = RADII[next] ?? from;
130
+
131
+ // Smoothstep the fraction so each shape settles before the next begins,
132
+ // rather than the outline sliding at a constant rate the whole time.
133
+ const t = value - rawIndex;
134
+ const eased = t * t * (3 - 2 * t);
135
+
136
+ return {
137
+ d: radiiToPath(blendRadii(from, to, eased), canvas / 2, radius),
138
+ };
139
+ });
140
+
141
+ const spinStyle = useAnimatedStyle(() => ({
142
+ transform: [{ rotate: `${spin.value}deg` }],
143
+ }));
144
+
145
+ if (ios)
146
+ return (
147
+ <View
148
+ className={className}
149
+ style={{
150
+ width: size,
151
+ height: size,
152
+ alignItems: 'center',
153
+ justifyContent: 'center',
154
+ borderRadius: size / 2,
155
+ backgroundColor: contained ? theme.surfaceContainerHigh : 'transparent',
156
+ }}
157
+ >
158
+ <ActivityIndicator
159
+ size={size <= 24 ? 'small' : 'large'}
160
+ color={resolvedColor}
161
+ accessibilityLabel="Loading"
162
+ />
163
+ </View>
164
+ );
165
+ return (
166
+ <View
167
+ className={cn('items-center justify-center', contained && 'rounded-full', className)}
168
+ style={{
169
+ width: size,
170
+ height: size,
171
+ backgroundColor: contained ? theme.surfaceContainerHighest : 'transparent',
172
+ }}
173
+ accessibilityRole="progressbar"
174
+ accessibilityLabel="Loading"
175
+ >
176
+ <Animated.View style={spinStyle}>
177
+ <Svg width={canvas} height={canvas}>
178
+ <AnimatedPath animatedProps={animatedProps} fill={resolvedColor} />
179
+ </Svg>
180
+ </Animated.View>
181
+ </View>
182
+ );
183
+ }