@startsimpli/ui-native 0.0.0-stage → 0.1.1

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -2
  3. package/package.json +60 -3
  4. package/src/__tests__/charts-math.test.ts +73 -0
  5. package/src/__tests__/styles-dashboard.test.ts +92 -0
  6. package/src/__tests__/styles-primitives.test.ts +114 -0
  7. package/src/__tests__/styles.test.ts +101 -0
  8. package/src/__tests__/theme.test.ts +23 -0
  9. package/src/__tests__/tokens.test.ts +33 -0
  10. package/src/charts-math.ts +106 -0
  11. package/src/components/AccentContext.tsx +22 -0
  12. package/src/components/Alert.tsx +27 -0
  13. package/src/components/Avatar.tsx +42 -0
  14. package/src/components/Badge.tsx +18 -0
  15. package/src/components/Button.tsx +40 -0
  16. package/src/components/Card.tsx +30 -0
  17. package/src/components/Checkbox.tsx +31 -0
  18. package/src/components/Chip.tsx +26 -0
  19. package/src/components/CompareChart.tsx +56 -0
  20. package/src/components/DataTable.tsx +89 -0
  21. package/src/components/DemographicBars.tsx +50 -0
  22. package/src/components/DonutChart.tsx +57 -0
  23. package/src/components/DonutLegend.tsx +49 -0
  24. package/src/components/DualRangeControl.tsx +31 -0
  25. package/src/components/EmptyState.tsx +63 -0
  26. package/src/components/ErrorBoundary.tsx +52 -0
  27. package/src/components/Flag.tsx +26 -0
  28. package/src/components/GradientBox.tsx +48 -0
  29. package/src/components/HeroBanner.tsx +160 -0
  30. package/src/components/Input.tsx +33 -0
  31. package/src/components/Label.tsx +9 -0
  32. package/src/components/ListRow.tsx +44 -0
  33. package/src/components/MetricRow.tsx +48 -0
  34. package/src/components/MultiLineLegend.tsx +30 -0
  35. package/src/components/Progress.tsx +18 -0
  36. package/src/components/Screen.tsx +38 -0
  37. package/src/components/SegmentedControl.tsx +31 -0
  38. package/src/components/Separator.tsx +13 -0
  39. package/src/components/ShareBar.tsx +32 -0
  40. package/src/components/Skeleton.tsx +29 -0
  41. package/src/components/SkeletonList.tsx +23 -0
  42. package/src/components/Sparkline.tsx +38 -0
  43. package/src/components/Spinner.tsx +8 -0
  44. package/src/components/StatTile.tsx +69 -0
  45. package/src/components/Switch.tsx +17 -0
  46. package/src/components/TabBar.tsx +50 -0
  47. package/src/components/Text.tsx +13 -0
  48. package/src/components/ThemeProvider.tsx +25 -0
  49. package/src/components/Timeline.tsx +47 -0
  50. package/src/components/TrendCard.tsx +39 -0
  51. package/src/components/TrendChart.tsx +68 -0
  52. package/src/index.ts +64 -0
  53. package/src/styles.ts +479 -0
  54. package/src/theme.ts +46 -0
  55. package/src/tokens.ts +145 -0
@@ -0,0 +1,49 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+ import { DonutChart } from './DonutChart';
4
+ import { Text } from './Text';
5
+
6
+ export interface DonutLegendItem {
7
+ label: string;
8
+ /** Segment magnitude. */
9
+ value: number;
10
+ color: string;
11
+ /** Optional secondary line under the label. */
12
+ caption?: string;
13
+ /** Right-aligned content (formatted value, badge, sparkline…). */
14
+ trailing?: ReactNode;
15
+ }
16
+
17
+ export interface DonutLegendProps {
18
+ items: DonutLegendItem[];
19
+ size?: number;
20
+ thickness?: number;
21
+ /** Content rendered in the donut hole. */
22
+ center?: ReactNode;
23
+ /** Gap between the donut and the legend. */
24
+ gap?: number;
25
+ }
26
+
27
+ /** Donut chart beside a colored-dot legend. The common "breakdown" block
28
+ * (revenue by source, fans by platform, …). */
29
+ export function DonutLegend({ items, size = 130, thickness = 16, center, gap = 16 }: DonutLegendProps) {
30
+ return (
31
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap }}>
32
+ <DonutChart size={size} thickness={thickness} segments={items.map((i) => ({ value: i.value, color: i.color }))}>
33
+ {center}
34
+ </DonutChart>
35
+ <View style={{ flex: 1, gap: 10 }}>
36
+ {items.map((it, idx) => (
37
+ <View key={`${it.label}-${idx}`} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
38
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: it.color }} />
39
+ <View style={{ flex: 1 }}>
40
+ <Text variant="muted">{it.label}</Text>
41
+ {it.caption ? <Text variant="caption">{it.caption}</Text> : null}
42
+ </View>
43
+ {it.trailing ? <View>{it.trailing}</View> : null}
44
+ </View>
45
+ ))}
46
+ </View>
47
+ </View>
48
+ );
49
+ }
@@ -0,0 +1,31 @@
1
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Text } from './Text';
3
+ import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
4
+
5
+ export interface DualRangeControlProps<T extends string | number> {
6
+ /** Comparison baselines, e.g. previous period / same period last year / none. */
7
+ options: SegmentedOption<T>[];
8
+ value: T;
9
+ onChange: (value: T) => void;
10
+ /** Leading label, e.g. "Compare to". */
11
+ label?: string;
12
+ style?: StyleProp<ViewStyle>;
13
+ }
14
+
15
+ /** Period-comparison selector: pick what the current range is measured against
16
+ * (the period math lives in @startsimpli/utils). A labeled SegmentedControl —
17
+ * generic over the baseline value type. */
18
+ export function DualRangeControl<T extends string | number>({
19
+ options,
20
+ value,
21
+ onChange,
22
+ label,
23
+ style,
24
+ }: DualRangeControlProps<T>) {
25
+ return (
26
+ <View style={[{ gap: 6 }, style]}>
27
+ {label ? <Text variant="muted">{label}</Text> : null}
28
+ <SegmentedControl options={options} value={value} onChange={onChange} />
29
+ </View>
30
+ );
31
+ }
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { Text } from './Text';
4
+ import { Button } from './Button';
5
+ import { useTheme } from './ThemeProvider';
6
+
7
+ export interface EmptyStateProps {
8
+ /** Large glyph above the title — an emoji string or a node (e.g. an icon). */
9
+ icon?: ReactNode;
10
+ title: string;
11
+ description?: string;
12
+ /** Optional primary action. Both label and handler must be set to render it. */
13
+ actionLabel?: string;
14
+ onAction?: () => void;
15
+ /** Layout overrides (e.g. margins), merged after the base style. */
16
+ style?: StyleProp<ViewStyle>;
17
+ }
18
+
19
+ /**
20
+ * Centered zero-data / first-run placeholder: optional glyph, a title, an
21
+ * optional description, and an optional primary action. Use it instead of
22
+ * leaving a screen blank (or showing a bare table header) when there's nothing
23
+ * to display yet.
24
+ */
25
+ export function EmptyState({ icon, title, description, actionLabel, onAction, style }: EmptyStateProps) {
26
+ const theme = useTheme();
27
+ return (
28
+ <View
29
+ style={[
30
+ {
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ paddingVertical: theme.spacing['2xl'],
34
+ paddingHorizontal: theme.spacing.xl,
35
+ gap: theme.spacing.sm,
36
+ },
37
+ style,
38
+ ]}
39
+ >
40
+ {icon != null
41
+ ? typeof icon === 'string'
42
+ ? <Text style={{ fontSize: 40, lineHeight: 48 }}>{icon}</Text>
43
+ : icon
44
+ : null}
45
+ <Text variant="heading" style={{ textAlign: 'center' }}>
46
+ {title}
47
+ </Text>
48
+ {description ? (
49
+ <Text variant="muted" style={{ textAlign: 'center' }}>
50
+ {description}
51
+ </Text>
52
+ ) : null}
53
+ {actionLabel && onAction ? (
54
+ <Button
55
+ title={actionLabel}
56
+ variant="primary"
57
+ onPress={onAction}
58
+ style={{ marginTop: theme.spacing.sm, alignSelf: 'stretch' }}
59
+ />
60
+ ) : null}
61
+ </View>
62
+ );
63
+ }
@@ -0,0 +1,52 @@
1
+ import { Component, type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+ import { Screen } from './Screen';
4
+ import { Text } from './Text';
5
+ import { Alert } from './Alert';
6
+ import { Button } from './Button';
7
+
8
+ export interface ErrorBoundaryProps {
9
+ children: ReactNode;
10
+ /** Custom fallback; receives a `reset` to clear the error and re-render. */
11
+ fallback?: (reset: () => void, error: Error) => ReactNode;
12
+ /** Side-effect on catch (logging/telemetry). */
13
+ onError?: (error: Error) => void;
14
+ }
15
+
16
+ interface State {
17
+ error: Error | null;
18
+ }
19
+
20
+ /** Catches render errors in its subtree and shows a fallback instead of an
21
+ * unmounted (blank) tree. Must be a class component (React error boundary). */
22
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
23
+ state: State = { error: null };
24
+
25
+ static getDerivedStateFromError(error: Error): State {
26
+ return { error };
27
+ }
28
+
29
+ componentDidCatch(error: Error) {
30
+ this.props.onError?.(error);
31
+ }
32
+
33
+ reset = () => this.setState({ error: null });
34
+
35
+ render() {
36
+ const { error } = this.state;
37
+ if (error) {
38
+ if (this.props.fallback) return this.props.fallback(this.reset, error);
39
+ return (
40
+ <Screen>
41
+ <View style={{ flex: 1, justifyContent: 'center', gap: 16 }}>
42
+ <Alert title="Something went wrong" variant="destructive">
43
+ An unexpected error occurred.
44
+ </Alert>
45
+ <Button title="Try again" variant="primary" onPress={this.reset} />
46
+ </View>
47
+ </Screen>
48
+ );
49
+ }
50
+ return this.props.children;
51
+ }
52
+ }
@@ -0,0 +1,26 @@
1
+ import { Image, type StyleProp, type ImageStyle } from 'react-native';
2
+
3
+ export interface FlagProps {
4
+ /** ISO 3166-1 alpha-2 country code (e.g. 'EG'), case-insensitive. */
5
+ code: string;
6
+ /** Flag width in px; height follows the standard 4:3 ratio. */
7
+ size?: number;
8
+ style?: StyleProp<ImageStyle>;
9
+ }
10
+
11
+ /**
12
+ * A real country-flag image. Emoji flags ("🇪🇬") fall back to tofu boxes on any
13
+ * platform without a regional-indicator emoji font (most browsers/OSs outside
14
+ * Apple), so UI uses this instead. Sourced from the flagcdn image CDN by code.
15
+ */
16
+ export function Flag({ code, size = 20, style }: FlagProps) {
17
+ const cc = code.trim().toLowerCase();
18
+ const height = Math.round((size * 3) / 4);
19
+ return (
20
+ <Image
21
+ accessibilityLabel={code.toUpperCase()}
22
+ source={{ uri: `https://flagcdn.com/w80/${cc}.png` }}
23
+ style={[{ width: size, height, borderRadius: 2 }, style]}
24
+ />
25
+ );
26
+ }
@@ -0,0 +1,48 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
4
+ import { useTheme } from './ThemeProvider';
5
+
6
+ export interface GradientBoxProps {
7
+ /** Two or more gradient colors, top-left to bottom-right by default. */
8
+ colors: string[];
9
+ /** Gradient direction as fractional coords. Defaults to a 140°-ish diagonal. */
10
+ start?: { x: number; y: number };
11
+ end?: { x: number; y: number };
12
+ borderRadius?: number;
13
+ style?: StyleProp<ViewStyle>;
14
+ children?: ReactNode;
15
+ }
16
+
17
+ let gradSeq = 0;
18
+
19
+ /** A box whose background is a linear gradient (via react-native-svg), with
20
+ * children rendered on top. Generic — heroes, banners, callouts. */
21
+ export function GradientBox({
22
+ colors,
23
+ start = { x: 0, y: 0 },
24
+ end = { x: 1, y: 1 },
25
+ borderRadius,
26
+ style,
27
+ children,
28
+ }: GradientBoxProps) {
29
+ const theme = useTheme();
30
+ const id = `grad${gradSeq++}`;
31
+ const radius = borderRadius ?? theme.radii.lg;
32
+ const stops = colors.length >= 2 ? colors : [colors[0] ?? theme.colors.primary, colors[0] ?? theme.colors.primary];
33
+ return (
34
+ <View style={[{ borderRadius: radius, overflow: 'hidden' }, style]}>
35
+ <Svg width="100%" height="100%" style={{ position: 'absolute' }}>
36
+ <Defs>
37
+ <LinearGradient id={id} x1={start.x} y1={start.y} x2={end.x} y2={end.y}>
38
+ {stops.map((c, i) => (
39
+ <Stop key={i} offset={stops.length === 1 ? 0 : i / (stops.length - 1)} stopColor={c} />
40
+ ))}
41
+ </LinearGradient>
42
+ </Defs>
43
+ <Rect x={0} y={0} width="100%" height="100%" fill={`url(#${id})`} />
44
+ </Svg>
45
+ {children}
46
+ </View>
47
+ );
48
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Full-bleed gradient hero region. Composes GradientBox with sensible defaults
3
+ * for a screen-top hero: rounded corners, generous padding, optional accent
4
+ * overlay. If `colors` is omitted, derives a 2-stop gradient from the current
5
+ * accent → deep slate, giving each screen the artist-immersive feel.
6
+ *
7
+ * Optional `backdrop` lays an image (e.g. an artist portrait) UNDER the
8
+ * gradient with low opacity, so the screen reads as "the artist's page".
9
+ *
10
+ * Generic — any app can use this for hero sections; the accent comes from
11
+ * AccentContext, so the colour rotates per the screen's context.
12
+ */
13
+ import { type ReactNode } from 'react';
14
+ import {
15
+ Image,
16
+ View,
17
+ type ImageSourcePropType,
18
+ type StyleProp,
19
+ type ViewStyle,
20
+ } from 'react-native';
21
+ import { GradientBox } from './GradientBox';
22
+ import { useTheme } from './ThemeProvider';
23
+ import { useAccent } from './AccentContext';
24
+
25
+ export interface HeroBannerProps {
26
+ /** Override the auto-derived gradient. Two or more colours. */
27
+ colors?: string[];
28
+ /** Override accent (otherwise pulled from AccentContext). */
29
+ accent?: string;
30
+ /**
31
+ * Optional image laid behind / over the gradient.
32
+ * - mode 'photo' (default): image is the visual, darkened-bottom overlay so
33
+ * text stays legible (Apple-Music album-page hero).
34
+ * - mode 'tint': accent gradient is the visual, image overlays at
35
+ * low opacity to add texture (data-forward heroes).
36
+ */
37
+ backdrop?: ImageSourcePropType;
38
+ /** How to layer the backdrop. Defaults to 'photo'. */
39
+ backdropMode?: 'photo' | 'tint';
40
+ /** Backdrop opacity (0–1). Defaults: photo=0.85, tint=0.22. */
41
+ backdropOpacity?: number;
42
+ /** Container style. Margins, padding, borderRadius overrides land here. */
43
+ style?: StyleProp<ViewStyle>;
44
+ /** Inner content (numbers, labels). */
45
+ children?: ReactNode;
46
+ }
47
+
48
+ export function HeroBanner({
49
+ colors,
50
+ accent,
51
+ backdrop,
52
+ backdropMode = 'photo',
53
+ backdropOpacity,
54
+ style,
55
+ children,
56
+ }: HeroBannerProps) {
57
+ const theme = useTheme();
58
+ const fallbackAccent = useAccent();
59
+ const stops = colors ?? [accent ?? fallbackAccent, theme.colors.background];
60
+ const opacity = backdropOpacity ?? (backdropMode === 'tint' ? 0.22 : 0.85);
61
+
62
+ // Tint mode: accent gradient is the visual; portrait sits over it at low
63
+ // opacity for texture. Children render on top of both.
64
+ if (backdrop && backdropMode === 'tint') {
65
+ return (
66
+ <View
67
+ style={[
68
+ { borderRadius: theme.radii.lg, overflow: 'hidden' },
69
+ style,
70
+ ]}
71
+ >
72
+ <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
73
+ <GradientBox
74
+ colors={stops}
75
+ start={{ x: 0, y: 0 }}
76
+ end={{ x: 1, y: 1 }}
77
+ style={{ width: '100%', height: '100%' }}
78
+ />
79
+ </View>
80
+ <View
81
+ pointerEvents="none"
82
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
83
+ >
84
+ <Image
85
+ source={backdrop}
86
+ style={{ width: '100%', height: '100%', opacity }}
87
+ resizeMode="cover"
88
+ />
89
+ </View>
90
+ <View
91
+ style={{
92
+ paddingVertical: theme.spacing.xl,
93
+ paddingHorizontal: theme.spacing.lg,
94
+ gap: theme.spacing.xs,
95
+ }}
96
+ >
97
+ {children}
98
+ </View>
99
+ </View>
100
+ );
101
+ }
102
+
103
+ // If a backdrop is provided we need a clipping wrapper so the image
104
+ // (absolute-positioned beneath the gradient layer) is rounded to the same
105
+ // borderRadius as the gradient. Otherwise the GradientBox draws directly.
106
+ if (backdrop) {
107
+ // Photo mode: artist photo at full size + a vertical darkening overlay so
108
+ // text on top stays legible. Accent isn't in the gradient here — the
109
+ // image itself is the visual.
110
+ return (
111
+ <View
112
+ style={[
113
+ {
114
+ borderRadius: theme.radii.lg,
115
+ overflow: 'hidden',
116
+ backgroundColor: theme.colors.background,
117
+ },
118
+ style,
119
+ ]}
120
+ >
121
+ <Image
122
+ source={backdrop}
123
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity }}
124
+ resizeMode="cover"
125
+ />
126
+ <GradientBox
127
+ colors={['#00000000', '#000000B3']}
128
+ start={{ x: 0, y: 0 }}
129
+ end={{ x: 0, y: 1 }}
130
+ style={{
131
+ paddingVertical: theme.spacing.xl,
132
+ paddingHorizontal: theme.spacing.lg,
133
+ gap: theme.spacing.xs,
134
+ }}
135
+ >
136
+ {children}
137
+ </GradientBox>
138
+ </View>
139
+ );
140
+ }
141
+
142
+ return (
143
+ <GradientBox
144
+ colors={stops}
145
+ start={{ x: 0, y: 0 }}
146
+ end={{ x: 1, y: 1 }}
147
+ borderRadius={theme.radii.lg}
148
+ style={[
149
+ {
150
+ paddingVertical: theme.spacing.xl,
151
+ paddingHorizontal: theme.spacing.lg,
152
+ gap: theme.spacing.xs,
153
+ },
154
+ style,
155
+ ]}
156
+ >
157
+ {children}
158
+ </GradientBox>
159
+ );
160
+ }
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react';
2
+ import { TextInput, type TextInputProps } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+ import { inputStyle } from '../styles';
5
+
6
+ export interface InputProps extends TextInputProps {
7
+ invalid?: boolean;
8
+ }
9
+
10
+ /** Themed single-line text field. Mirrors @startsimpli/ui input. */
11
+ export function Input({ invalid = false, style, onFocus, onBlur, ...rest }: InputProps) {
12
+ const theme = useTheme();
13
+ const [focused, setFocused] = useState(false);
14
+ return (
15
+ <TextInput
16
+ placeholderTextColor={theme.colors.mutedForeground}
17
+ style={[
18
+ inputStyle(theme, { invalid }),
19
+ focused ? { borderColor: theme.colors.ring } : null,
20
+ style,
21
+ ]}
22
+ onFocus={(e) => {
23
+ setFocused(true);
24
+ onFocus?.(e);
25
+ }}
26
+ onBlur={(e) => {
27
+ setFocused(false);
28
+ onBlur?.(e);
29
+ }}
30
+ {...rest}
31
+ />
32
+ );
33
+ }
@@ -0,0 +1,9 @@
1
+ import { Text as RNText, type TextProps } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { labelStyle } from '../styles';
4
+
5
+ /** Form field label. */
6
+ export function Label({ style, ...rest }: TextProps) {
7
+ const theme = useTheme();
8
+ return <RNText style={[labelStyle(theme), style]} {...rest} />;
9
+ }
@@ -0,0 +1,44 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View, Pressable, Text as RNText, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+ import { listRowStyle, listRowTitleStyle, listRowSubtitleStyle } from '../styles';
5
+
6
+ export interface ListRowProps {
7
+ title: string;
8
+ subtitle?: string;
9
+ /** Leading node, e.g. an Avatar or icon. */
10
+ leading?: ReactNode;
11
+ /** Trailing node, e.g. a value, Badge, or chevron. */
12
+ trailing?: ReactNode;
13
+ /** When set, the row becomes a pressable. */
14
+ onPress?: () => void;
15
+ style?: StyleProp<ViewStyle>;
16
+ }
17
+
18
+ /** Leading · (title / subtitle) · trailing row. Pressable when `onPress` is set. */
19
+ export function ListRow({ title, subtitle, leading, trailing, onPress, style }: ListRowProps) {
20
+ const theme = useTheme();
21
+ const content = (
22
+ <>
23
+ {leading ? <View>{leading}</View> : null}
24
+ <View style={{ flex: 1 }}>
25
+ <RNText style={listRowTitleStyle(theme)}>{title}</RNText>
26
+ {subtitle ? <RNText style={listRowSubtitleStyle(theme)}>{subtitle}</RNText> : null}
27
+ </View>
28
+ {trailing ? <View>{trailing}</View> : null}
29
+ </>
30
+ );
31
+
32
+ if (onPress) {
33
+ return (
34
+ <Pressable
35
+ accessibilityRole="button"
36
+ onPress={onPress}
37
+ style={({ pressed }) => [listRowStyle(theme), pressed ? { opacity: 0.6 } : null, style]}
38
+ >
39
+ {content}
40
+ </Pressable>
41
+ );
42
+ }
43
+ return <View style={[listRowStyle(theme), style]}>{content}</View>;
44
+ }
@@ -0,0 +1,48 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View, Pressable, Text as RNText, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+ import { Sparkline } from './Sparkline';
5
+ import { listRowStyle, listRowTitleStyle, listRowSubtitleStyle } from '../styles';
6
+
7
+ export interface MetricRowProps {
8
+ /** Leading node (logo / avatar / icon). */
9
+ leading?: ReactNode;
10
+ title: string;
11
+ subtitle?: string;
12
+ /** Optional inline trend sparkline between subtitle and trailing. */
13
+ trend?: number[];
14
+ trendColor?: string;
15
+ /** Trailing node (delta badge, value column, chevron…). */
16
+ trailing?: ReactNode;
17
+ onPress?: () => void;
18
+ style?: StyleProp<ViewStyle>;
19
+ }
20
+
21
+ /** Leading · (title/subtitle) · sparkline · trailing row — a ListRow augmented
22
+ * with an inline trend. Pressable when `onPress` is set. */
23
+ export function MetricRow({ leading, title, subtitle, trend, trendColor, trailing, onPress, style }: MetricRowProps) {
24
+ const theme = useTheme();
25
+ const content = (
26
+ <>
27
+ {leading ? <View>{leading}</View> : null}
28
+ <View style={{ flex: 1 }}>
29
+ <RNText style={listRowTitleStyle(theme)}>{title}</RNText>
30
+ {subtitle ? <RNText style={listRowSubtitleStyle(theme)}>{subtitle}</RNText> : null}
31
+ </View>
32
+ {trend && trend.length > 0 ? <Sparkline data={trend} color={trendColor} width={54} height={22} /> : null}
33
+ {trailing ? <View>{trailing}</View> : null}
34
+ </>
35
+ );
36
+ if (onPress) {
37
+ return (
38
+ <Pressable
39
+ accessibilityRole="button"
40
+ onPress={onPress}
41
+ style={({ pressed }) => [listRowStyle(theme), pressed ? { opacity: 0.6 } : null, style]}
42
+ >
43
+ {content}
44
+ </Pressable>
45
+ );
46
+ }
47
+ return <View style={[listRowStyle(theme), style]}>{content}</View>;
48
+ }
@@ -0,0 +1,30 @@
1
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Text } from './Text';
3
+
4
+ export interface MultiLineLegendItem {
5
+ label: string;
6
+ color: string;
7
+ /** Optional right-aligned pre-formatted value (e.g. "1.2M"). */
8
+ value?: string;
9
+ }
10
+
11
+ export interface MultiLineLegendProps {
12
+ items: MultiLineLegendItem[];
13
+ style?: StyleProp<ViewStyle>;
14
+ }
15
+
16
+ /** Color-swatch legend for multi-series line charts (CompareChart). The swatch
17
+ * is a short line — signalling a series, not a donut slice. Wraps to fit. */
18
+ export function MultiLineLegend({ items, style }: MultiLineLegendProps) {
19
+ return (
20
+ <View style={[{ flexDirection: 'row', flexWrap: 'wrap', gap: 14 }, style]}>
21
+ {items.map((it, i) => (
22
+ <View key={`${it.label}-${i}`} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
23
+ <View style={{ width: 14, height: 3, borderRadius: 2, backgroundColor: it.color }} />
24
+ <Text variant="muted">{it.label}</Text>
25
+ {it.value ? <Text variant="caption">{it.value}</Text> : null}
26
+ </View>
27
+ ))}
28
+ </View>
29
+ );
30
+ }
@@ -0,0 +1,18 @@
1
+ import { View } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { progressTrackStyle, progressFillStyle } from '../styles';
4
+
5
+ export interface ProgressProps {
6
+ /** 0–100. */
7
+ value: number;
8
+ }
9
+
10
+ /** Determinate progress bar. */
11
+ export function Progress({ value }: ProgressProps) {
12
+ const theme = useTheme();
13
+ return (
14
+ <View style={progressTrackStyle(theme)} accessibilityRole="progressbar" accessibilityValue={{ now: value, min: 0, max: 100 }}>
15
+ <View style={progressFillStyle(theme, value)} />
16
+ </View>
17
+ );
18
+ }
@@ -0,0 +1,38 @@
1
+ import { ScrollView, View, type ViewProps, type ViewStyle } from 'react-native';
2
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
+ import { useTheme } from './ThemeProvider';
4
+
5
+ export interface ScreenProps extends ViewProps {
6
+ /** Wrap content in a ScrollView (for screens taller than the viewport). */
7
+ scroll?: boolean;
8
+ contentContainerStyle?: ViewStyle;
9
+ }
10
+
11
+ /**
12
+ * Full-bleed page surface: theme background, standard padding, and safe-area
13
+ * insets so content clears the status bar / notch and home indicator.
14
+ */
15
+ export function Screen({ scroll = false, style, contentContainerStyle, children, ...rest }: ScreenProps) {
16
+ const theme = useTheme();
17
+ const insets = useSafeAreaInsets();
18
+
19
+ const padding: ViewStyle = {
20
+ paddingTop: insets.top + theme.spacing.sm,
21
+ paddingBottom: insets.bottom + theme.spacing.sm,
22
+ paddingHorizontal: theme.spacing.lg,
23
+ };
24
+
25
+ if (scroll) {
26
+ return (
27
+ <View style={[{ flex: 1, backgroundColor: theme.colors.background }, style]} {...rest}>
28
+ <ScrollView contentContainerStyle={[padding, contentContainerStyle]}>{children}</ScrollView>
29
+ </View>
30
+ );
31
+ }
32
+
33
+ return (
34
+ <View style={[{ flex: 1, backgroundColor: theme.colors.background }, padding, style]} {...rest}>
35
+ {children}
36
+ </View>
37
+ );
38
+ }