@compsych/mobile-ui 1.0.10 → 1.0.13

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActionSheet/index.tsx +124 -57
  3. package/src/components/Alert/index.tsx +19 -10
  4. package/src/components/Avatar/index.tsx +95 -92
  5. package/src/components/Badge/index.tsx +39 -36
  6. package/src/components/BodyText/index.tsx +5 -5
  7. package/src/components/Breadcrumb/index.tsx +32 -30
  8. package/src/components/Button/Button.test.tsx +13 -7
  9. package/src/components/Button/index.tsx +114 -92
  10. package/src/components/ChatBubble/ChatBubble.test.tsx +77 -0
  11. package/src/components/ChatBubble/index.tsx +187 -0
  12. package/src/components/ChatInput/ChatInput.test.tsx +90 -0
  13. package/src/components/ChatInput/index.tsx +263 -0
  14. package/src/components/Checkbox/index.tsx +24 -21
  15. package/src/components/Chip/index.tsx +75 -72
  16. package/src/components/Divider/index.tsx +3 -3
  17. package/src/components/EmptyState/index.tsx +72 -55
  18. package/src/components/HeaderText/index.tsx +5 -5
  19. package/src/components/Input/index.tsx +40 -37
  20. package/src/components/List/index.tsx +2 -3
  21. package/src/components/Pagination/index.tsx +56 -43
  22. package/src/components/PlanCard/index.tsx +118 -50
  23. package/src/components/ProgressTracker/index.tsx +44 -26
  24. package/src/components/PromotionCard/index.tsx +217 -212
  25. package/src/components/RadioButton/index.tsx +27 -25
  26. package/src/components/ScreenContainer/ScreenContainer.test.tsx +12 -2
  27. package/src/components/ScreenContainer/index.tsx +22 -12
  28. package/src/components/SegmentedControl/index.tsx +37 -30
  29. package/src/components/SelectionCard/index.tsx +38 -26
  30. package/src/components/ServiceCard/index.tsx +162 -130
  31. package/src/components/Slider/index.tsx +77 -77
  32. package/src/components/Snackbar/index.tsx +69 -57
  33. package/src/components/Switch/index.tsx +13 -12
  34. package/src/components/Tooltip/index.tsx +3 -3
  35. package/src/index.ts +13 -0
  36. package/src/theme/ThemeContext.ts +11 -0
  37. package/src/theme/ThemeProvider.tsx +40 -0
  38. package/src/theme/__tests__/ThemeProvider.test.tsx +101 -0
  39. package/src/theme/__tests__/defaultTheme.test.ts +36 -0
  40. package/src/theme/__tests__/mergeTheme.test.ts +94 -0
  41. package/src/theme/defaultTheme.ts +15 -0
  42. package/src/theme/index.ts +8 -0
  43. package/src/theme/mergeTheme.ts +47 -0
  44. package/src/theme/types.ts +39 -0
  45. package/src/theme/useTheme.ts +16 -0
@@ -4,7 +4,7 @@ import { Text, type TextProps } from 'react-native';
4
4
 
5
5
  import { useFonts } from 'expo-font';
6
6
 
7
- import { sys } from '../../tokens';
7
+ import { useTheme } from '../../theme';
8
8
 
9
9
  export type BodyVariant =
10
10
  | 'large'
@@ -26,7 +26,7 @@ const FONT_FAMILY: Record<string, string> = {
26
26
  semibold: 'GoogleSans_600SemiBold',
27
27
  };
28
28
 
29
- const VARIANT_TOKEN_KEY: Record<BodyVariant, keyof typeof sys.typeScale> = {
29
+ const VARIANT_TOKEN_KEY: Record<BodyVariant, string> = {
30
30
  large: 'bodyLarge',
31
31
  medium: 'bodyMedium',
32
32
  small: 'bodySmall',
@@ -35,8 +35,6 @@ const VARIANT_TOKEN_KEY: Record<BodyVariant, keyof typeof sys.typeScale> = {
35
35
  labelSmall: 'labelSmall',
36
36
  };
37
37
 
38
- const { typeScale: ts } = sys;
39
-
40
38
  export function BodyText({
41
39
  variant = 'medium',
42
40
  emphasized = false,
@@ -44,13 +42,15 @@ export function BodyText({
44
42
  style,
45
43
  ...rest
46
44
  }: BodyTextProps) {
45
+ const { typeScale: ts } = useTheme();
46
+
47
47
  const [fontsLoaded] = useFonts({
48
48
  GoogleSans_400Regular: require('../../fonts/GoogleSans_400Regular.ttf'),
49
49
  GoogleSans_500Medium: require('../../fonts/GoogleSans_500Medium.ttf'),
50
50
  GoogleSans_600SemiBold: require('../../fonts/GoogleSans_600SemiBold.ttf'),
51
51
  });
52
52
 
53
- const token = ts[VARIANT_TOKEN_KEY[variant]];
53
+ const token = ts[VARIANT_TOKEN_KEY[variant] as keyof typeof ts];
54
54
  const weight = emphasized
55
55
  ? token.sysFontWeightEmphasized
56
56
  : token.sysFontWeight;
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
 
3
3
  import {
4
4
  Pressable,
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  import { Ionicons } from '@expo/vector-icons';
14
14
 
15
- import { sys } from '../../tokens';
15
+ import { useTheme } from '../../theme';
16
16
 
17
17
  export type BreadcrumbSize = 'sm' | 'lg';
18
18
 
@@ -38,31 +38,34 @@ export interface BreadcrumbProps {
38
38
  style?: StyleProp<ViewStyle>;
39
39
  }
40
40
 
41
- const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
41
+ export function Breadcrumb({ items, size = 'lg', style }: BreadcrumbProps) {
42
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = useTheme();
42
43
 
43
- const SIZE_TOKENS = {
44
- sm: {
45
- fontSize: ts.labelSmall.sysFontSize,
46
- lineHeight: ts.labelSmall.sysLineHeight,
47
- letterSpacing: ts.labelSmall.sysTracking,
48
- paddingH: dim.spacing.padding.sysPadding8,
49
- paddingV: dim.spacing.padding.sysPadding2,
50
- iconSize: 16,
51
- dividerWidth: 8,
52
- },
53
- lg: {
54
- fontSize: ts.labelMedium.sysFontSize,
55
- lineHeight: ts.labelMedium.sysLineHeight,
56
- letterSpacing: ts.labelMedium.sysTracking,
57
- paddingH: dim.spacing.padding.sysPadding8,
58
- paddingV: dim.spacing.padding.sysPadding4,
59
- iconSize: 16,
60
- dividerWidth: 8,
61
- },
62
- };
44
+ const sizeTokens = useMemo(
45
+ () => ({
46
+ sm: {
47
+ fontSize: ts.labelSmall.sysFontSize,
48
+ lineHeight: ts.labelSmall.sysLineHeight,
49
+ letterSpacing: ts.labelSmall.sysTracking,
50
+ paddingH: dim.spacing.padding.sysPadding8,
51
+ paddingV: dim.spacing.padding.sysPadding2,
52
+ iconSize: 16,
53
+ dividerWidth: 8,
54
+ },
55
+ lg: {
56
+ fontSize: ts.labelMedium.sysFontSize,
57
+ lineHeight: ts.labelMedium.sysLineHeight,
58
+ letterSpacing: ts.labelMedium.sysTracking,
59
+ paddingH: dim.spacing.padding.sysPadding8,
60
+ paddingV: dim.spacing.padding.sysPadding4,
61
+ iconSize: 16,
62
+ dividerWidth: 8,
63
+ },
64
+ }),
65
+ [dim, ts],
66
+ );
63
67
 
64
- export function Breadcrumb({ items, size = 'lg', style }: BreadcrumbProps) {
65
- const s = SIZE_TOKENS[size];
68
+ const s = sizeTokens[size];
66
69
 
67
70
  return (
68
71
  // ScrollView lets a long breadcrumb scroll horizontally without clipping
@@ -130,7 +133,10 @@ export function Breadcrumb({ items, size = 'lg', style }: BreadcrumbProps) {
130
133
  accessibilityLabel={item.isHome ? 'Home' : item.label}
131
134
  style={({ pressed }) => [
132
135
  styles.pressable,
133
- pressed && styles.pressed,
136
+ pressed && {
137
+ backgroundColor: cr.transparent.neutral.sysBlack10,
138
+ borderRadius: 4,
139
+ },
134
140
  ]}
135
141
  >
136
142
  {content}
@@ -183,10 +189,6 @@ const styles = StyleSheet.create({
183
189
  pressable: {
184
190
  borderRadius: 4,
185
191
  },
186
- pressed: {
187
- backgroundColor: cr.transparent.neutral.sysBlack10,
188
- borderRadius: 4,
189
- },
190
192
  content: {
191
193
  flexDirection: 'row',
192
194
  alignItems: 'center',
@@ -22,13 +22,19 @@ describe('Button', () => {
22
22
  expect(getByRole('button').props.accessibilityState.disabled).toBe(true);
23
23
  });
24
24
 
25
- it.each(['filled', 'tonal', 'outlined', 'elevated', 'text'] as const)(
26
- 'renders variant "%s" without crashing',
27
- (variant) => {
28
- const { getByText } = render(<Button label="Button" variant={variant} />);
29
- expect(getByText('Button')).toBeTruthy();
30
- },
31
- );
25
+ it.each([
26
+ 'filled',
27
+ 'tonal',
28
+ 'outlined',
29
+ 'elevated',
30
+ 'text',
31
+ 'danger',
32
+ 'danger-outlined',
33
+ 'warning',
34
+ ] as const)('renders variant "%s" without crashing', (variant) => {
35
+ const { getByText } = render(<Button label="Button" variant={variant} />);
36
+ expect(getByText('Button')).toBeTruthy();
37
+ });
32
38
 
33
39
  it.each(['sm', 'md', 'lg', 'xl'] as const)(
34
40
  'renders size "%s" without crashing',
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
 
3
3
  import {
4
4
  ActivityIndicator,
@@ -9,7 +9,7 @@ import {
9
9
  View,
10
10
  } from 'react-native';
11
11
 
12
- import { sys } from '../../tokens';
12
+ import { useTheme } from '../../theme';
13
13
 
14
14
  export type ButtonVariant =
15
15
  | 'filled'
@@ -18,7 +18,8 @@ export type ButtonVariant =
18
18
  | 'elevated'
19
19
  | 'text'
20
20
  | 'danger'
21
- | 'danger-outlined';
21
+ | 'danger-outlined'
22
+ | 'warning';
22
23
 
23
24
  export type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
24
25
 
@@ -37,91 +38,6 @@ export interface ButtonProps extends Omit<
37
38
  trailingIcon?: React.ReactNode;
38
39
  }
39
40
 
40
- const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
41
-
42
- const VARIANT_TOKENS: Record<
43
- ButtonVariant,
44
- { bg: string; label: string; borderColor: string; borderWidth: number }
45
- > = {
46
- filled: {
47
- bg: cr.accent.primary.sysPrimary,
48
- label: cr.accent.primary.sysOnPrimary,
49
- borderColor: 'transparent',
50
- borderWidth: 0,
51
- },
52
- tonal: {
53
- bg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
54
- label: cr.addOn.primaryFixed.sysOnPrimaryFixed,
55
- borderColor: 'transparent',
56
- borderWidth: 0,
57
- },
58
- outlined: {
59
- bg: 'transparent',
60
- label: cr.surface.surface.sysOnSurface,
61
- borderColor: cr.outline.sysOutline,
62
- borderWidth: dim.borderWidth.sysStrokeThin,
63
- },
64
- elevated: {
65
- bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
66
- label: cr.surface.surface.sysOnSurface,
67
- borderColor: 'transparent',
68
- borderWidth: 0,
69
- },
70
- text: {
71
- bg: 'transparent',
72
- label: cr.surface.surface.sysOnSurface,
73
- borderColor: 'transparent',
74
- borderWidth: 0,
75
- },
76
- danger: {
77
- bg: cr.error.sysError,
78
- label: cr.error.sysOnError,
79
- borderColor: 'transparent',
80
- borderWidth: 0,
81
- },
82
- 'danger-outlined': {
83
- bg: 'transparent',
84
- label: cr.error.sysError,
85
- borderColor: cr.error.sysErrorContainer,
86
- borderWidth: dim.borderWidth.sysStrokeThin,
87
- },
88
- };
89
-
90
- const SIZE_TOKENS = {
91
- sm: {
92
- height: 32,
93
- paddingH: dim.spacing.padding.sysPadding12,
94
- paddingV: dim.spacing.padding.sysPadding4,
95
- fontSize: ts.labelSmall.sysFontSize,
96
- lineHeight: ts.labelSmall.sysLineHeight,
97
- iconSize: sys.iconography.sysSizeXs,
98
- },
99
- md: {
100
- height: 40,
101
- paddingH: dim.spacing.padding.sysPadding16,
102
- paddingV: dim.spacing.padding.sysPadding8,
103
- fontSize: ts.labelMedium.sysFontSize,
104
- lineHeight: ts.labelMedium.sysLineHeight,
105
- iconSize: sys.iconography.sysSizeXs,
106
- },
107
- lg: {
108
- height: 48,
109
- paddingH: dim.spacing.padding.sysPadding24,
110
- paddingV: dim.spacing.padding.sysPadding12,
111
- fontSize: ts.labelLarge.sysFontSize,
112
- lineHeight: ts.labelLarge.sysLineHeight,
113
- iconSize: sys.iconography.sysSizeSm,
114
- },
115
- xl: {
116
- height: 56,
117
- paddingH: dim.spacing.padding.sysPadding32,
118
- paddingV: dim.spacing.padding.sysPadding16,
119
- fontSize: ts.titleSmall.sysFontSize,
120
- lineHeight: ts.titleSmall.sysLineHeight,
121
- iconSize: sys.iconography.sysSizeMd,
122
- },
123
- };
124
-
125
41
  export function Button({
126
42
  variant = 'filled',
127
43
  size = 'md',
@@ -136,6 +52,109 @@ export function Button({
136
52
  accessibilityLabel,
137
53
  ...rest
138
54
  }: ButtonProps) {
55
+ const {
56
+ colorRoles: cr,
57
+ dimensions: dim,
58
+ typeScale: ts,
59
+ iconography: ico,
60
+ } = useTheme();
61
+
62
+ const VARIANT_TOKENS = useMemo(
63
+ () =>
64
+ ({
65
+ filled: {
66
+ bg: cr.accent.primary.sysPrimary,
67
+ label: cr.accent.primary.sysOnPrimary,
68
+ borderColor: 'transparent',
69
+ borderWidth: 0,
70
+ },
71
+ tonal: {
72
+ bg: cr.addOn.primaryFixed.sysPrimaryFixedDim,
73
+ label: cr.addOn.primaryFixed.sysOnPrimaryFixed,
74
+ borderColor: 'transparent',
75
+ borderWidth: 0,
76
+ },
77
+ outlined: {
78
+ bg: 'transparent',
79
+ label: cr.surface.surface.sysOnSurface,
80
+ borderColor: cr.outline.sysOutline,
81
+ borderWidth: dim.borderWidth.sysStrokeThin,
82
+ },
83
+ elevated: {
84
+ bg: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
85
+ label: cr.surface.surface.sysOnSurface,
86
+ borderColor: 'transparent',
87
+ borderWidth: 0,
88
+ },
89
+ text: {
90
+ bg: 'transparent',
91
+ label: cr.surface.surface.sysOnSurface,
92
+ borderColor: 'transparent',
93
+ borderWidth: 0,
94
+ },
95
+ danger: {
96
+ bg: cr.error.sysError,
97
+ label: cr.error.sysOnError,
98
+ borderColor: 'transparent',
99
+ borderWidth: 0,
100
+ },
101
+ 'danger-outlined': {
102
+ bg: 'transparent',
103
+ label: cr.error.sysError,
104
+ borderColor: cr.error.sysErrorContainer,
105
+ borderWidth: dim.borderWidth.sysStrokeThin,
106
+ },
107
+ warning: {
108
+ bg: cr.custom.warning.sysWarning,
109
+ label: cr.custom.warning.sysOnWarning,
110
+ borderColor: 'transparent',
111
+ borderWidth: 0,
112
+ },
113
+ }) as Record<
114
+ ButtonVariant,
115
+ { bg: string; label: string; borderColor: string; borderWidth: number }
116
+ >,
117
+ [cr, dim],
118
+ );
119
+
120
+ const SIZE_TOKENS = useMemo(
121
+ () => ({
122
+ sm: {
123
+ height: 32,
124
+ paddingH: dim.spacing.padding.sysPadding12,
125
+ paddingV: dim.spacing.padding.sysPadding4,
126
+ fontSize: ts.labelSmall.sysFontSize,
127
+ lineHeight: ts.labelSmall.sysLineHeight,
128
+ iconSize: ico.sysSizeXs,
129
+ },
130
+ md: {
131
+ height: 40,
132
+ paddingH: dim.spacing.padding.sysPadding16,
133
+ paddingV: dim.spacing.padding.sysPadding8,
134
+ fontSize: ts.labelMedium.sysFontSize,
135
+ lineHeight: ts.labelMedium.sysLineHeight,
136
+ iconSize: ico.sysSizeXs,
137
+ },
138
+ lg: {
139
+ height: 48,
140
+ paddingH: dim.spacing.padding.sysPadding24,
141
+ paddingV: dim.spacing.padding.sysPadding12,
142
+ fontSize: ts.labelLarge.sysFontSize,
143
+ lineHeight: ts.labelLarge.sysLineHeight,
144
+ iconSize: ico.sysSizeSm,
145
+ },
146
+ xl: {
147
+ height: 56,
148
+ paddingH: dim.spacing.padding.sysPadding32,
149
+ paddingV: dim.spacing.padding.sysPadding16,
150
+ fontSize: ts.titleSmall.sysFontSize,
151
+ lineHeight: ts.titleSmall.sysLineHeight,
152
+ iconSize: ico.sysSizeMd,
153
+ },
154
+ }),
155
+ [dim, ts, ico],
156
+ );
157
+
139
158
  const v = VARIANT_TOKENS[variant];
140
159
  const s = SIZE_TOKENS[size];
141
160
  const isDisabled = disabled || loading;
@@ -172,13 +191,16 @@ export function Button({
172
191
  }),
173
192
  ...(fullWidth && !iconOnly ? { alignSelf: 'stretch' } : {}),
174
193
  opacity: isDisabled ? 0.48 : pressed ? 0.82 : 1,
194
+ // Elevated: Figma specifies two drop shadows — RN only supports one
195
+ // on iOS, so we use the larger/more visible one (lv2: 0px 4px 16px
196
+ // rgba(0,0,0,0.10)) and fall back to elevation: 2 on Android.
175
197
  ...(variant === 'elevated'
176
198
  ? {
177
199
  shadowColor: '#000',
178
- shadowOffset: { width: 0, height: 2 },
179
- shadowOpacity: 0.06,
180
- shadowRadius: 4,
181
- elevation: 1,
200
+ shadowOffset: { width: 0, height: 4 },
201
+ shadowOpacity: 0.1,
202
+ shadowRadius: 16,
203
+ elevation: 2,
182
204
  }
183
205
  : {}),
184
206
  },
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { ChatBubble } from './index';
6
+
7
+ jest.mock('@expo/vector-icons', () => ({
8
+ Ionicons: 'Ionicons',
9
+ }));
10
+
11
+ describe('ChatBubble', () => {
12
+ it('renders message for incoming variant', () => {
13
+ const { getByText } = render(
14
+ <ChatBubble variant="incoming" message="Hello, how can I help?" />,
15
+ );
16
+ expect(getByText('Hello, how can I help?')).toBeTruthy();
17
+ });
18
+
19
+ it('renders message for outgoing variant', () => {
20
+ const { getByText } = render(
21
+ <ChatBubble variant="outgoing" message="I need some help" />,
22
+ );
23
+ expect(getByText('I need some help')).toBeTruthy();
24
+ });
25
+
26
+ it('renders timestamp when provided', () => {
27
+ const { getByText } = render(
28
+ <ChatBubble variant="incoming" message="Hi" timestamp="11:30 AM" />,
29
+ );
30
+ expect(getByText('11:30 AM')).toBeTruthy();
31
+ });
32
+
33
+ it('does not render timestamp when omitted', () => {
34
+ const { queryByText } = render(
35
+ <ChatBubble variant="incoming" message="Hi" />,
36
+ );
37
+ expect(queryByText('11:30 AM')).toBeNull();
38
+ });
39
+
40
+ it('renders thumbs-up button and calls onThumbsUp when pressed', () => {
41
+ const onThumbsUp = jest.fn();
42
+ const { getByLabelText } = render(
43
+ <ChatBubble variant="incoming" message="Hi" onThumbsUp={onThumbsUp} />,
44
+ );
45
+ fireEvent.press(getByLabelText('Thumbs up'));
46
+ expect(onThumbsUp).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it('renders thumbs-down button and calls onThumbsDown when pressed', () => {
50
+ const onThumbsDown = jest.fn();
51
+ const { getByLabelText } = render(
52
+ <ChatBubble
53
+ variant="incoming"
54
+ message="Hi"
55
+ onThumbsDown={onThumbsDown}
56
+ />,
57
+ );
58
+ fireEvent.press(getByLabelText('Thumbs down'));
59
+ expect(onThumbsDown).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it('does not render reaction buttons for outgoing variant', () => {
63
+ const onThumbsUp = jest.fn();
64
+ const { queryByLabelText } = render(
65
+ <ChatBubble variant="outgoing" message="Hey" onThumbsUp={onThumbsUp} />,
66
+ );
67
+ expect(queryByLabelText('Thumbs up')).toBeNull();
68
+ });
69
+
70
+ it('does not render reaction buttons when handlers are not provided', () => {
71
+ const { queryByLabelText } = render(
72
+ <ChatBubble variant="incoming" message="Hi" />,
73
+ );
74
+ expect(queryByLabelText('Thumbs up')).toBeNull();
75
+ expect(queryByLabelText('Thumbs down')).toBeNull();
76
+ });
77
+ });
@@ -0,0 +1,187 @@
1
+ import React, { useMemo } from 'react';
2
+
3
+ import {
4
+ Pressable,
5
+ StyleProp,
6
+ StyleSheet,
7
+ View,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+
11
+ import { Ionicons } from '@expo/vector-icons';
12
+
13
+ import { useTheme } from '../../theme';
14
+ import { BodyText } from '../BodyText';
15
+
16
+ export type ChatBubbleVariant = 'incoming' | 'outgoing';
17
+
18
+ export interface ChatBubbleProps {
19
+ variant: ChatBubbleVariant;
20
+ message: string;
21
+ timestamp?: string;
22
+ onThumbsUp?: () => void;
23
+ onThumbsDown?: () => void;
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+
27
+ export function ChatBubble({
28
+ variant,
29
+ message,
30
+ timestamp,
31
+ onThumbsUp,
32
+ onThumbsDown,
33
+ style,
34
+ }: ChatBubbleProps) {
35
+ const { colorRoles: cr, dimensions: dim, iconography: ico } = useTheme();
36
+
37
+ const isOutgoing = variant === 'outgoing';
38
+
39
+ const tokenStyles = useMemo(
40
+ () => ({
41
+ bubble: {
42
+ maxWidth: '80%' as const,
43
+ paddingVertical: dim.spacing.padding.sysPadding8,
44
+ paddingHorizontal: dim.spacing.padding.sysPadding12,
45
+ borderRadius: dim.borderRadius.sysRadiusMd,
46
+ },
47
+ bubbleIncoming: {
48
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
49
+ borderWidth: dim.borderWidth.sysStrokeThin,
50
+ borderColor: cr.outline.sysOutlineVariant,
51
+ borderBottomLeftRadius: dim.borderRadius.sysRadiusXs,
52
+ },
53
+ bubbleOutgoing: {
54
+ backgroundColor: cr.accent.primary.sysPrimary,
55
+ borderBottomRightRadius: dim.borderRadius.sysRadiusXs,
56
+ },
57
+ metaRow: {
58
+ flexDirection: 'row' as const,
59
+ alignItems: 'center' as const,
60
+ gap: dim.spacing.padding.sysPadding8,
61
+ marginTop: dim.spacing.padding.sysPadding4,
62
+ },
63
+ reactionBtn: {
64
+ padding: dim.spacing.padding.sysPadding2,
65
+ },
66
+ }),
67
+ [cr, dim],
68
+ );
69
+
70
+ const textColor = isOutgoing
71
+ ? cr.accent.primary.sysOnPrimary
72
+ : cr.surface.surface.sysOnSurface;
73
+ const timestampColor = isOutgoing
74
+ ? 'rgba(255,255,255,0.64)'
75
+ : cr.surface.surface.sysOnSurfaceVariant;
76
+ const iconColor = cr.surface.surface.sysOnSurfaceVariant;
77
+ const iconSize = ico.sysSizeXs;
78
+
79
+ return (
80
+ <View
81
+ style={[
82
+ styles.row,
83
+ isOutgoing ? styles.rowOutgoing : styles.rowIncoming,
84
+ style,
85
+ ]}
86
+ >
87
+ <View>
88
+ {/* Bubble */}
89
+ <View
90
+ style={[
91
+ tokenStyles.bubble,
92
+ isOutgoing
93
+ ? tokenStyles.bubbleOutgoing
94
+ : tokenStyles.bubbleIncoming,
95
+ ]}
96
+ >
97
+ <BodyText
98
+ variant="medium"
99
+ color={textColor}
100
+ style={styles.messageText}
101
+ >
102
+ {message}
103
+ </BodyText>
104
+ </View>
105
+
106
+ {/* Meta row: timestamp + reactions */}
107
+ <View
108
+ style={[
109
+ tokenStyles.metaRow,
110
+ isOutgoing ? styles.metaOutgoing : styles.metaIncoming,
111
+ ]}
112
+ >
113
+ {timestamp && (
114
+ <BodyText variant="labelSmall" color={timestampColor}>
115
+ {timestamp}
116
+ </BodyText>
117
+ )}
118
+
119
+ {/* Reaction buttons — incoming only */}
120
+ {!isOutgoing && (onThumbsUp || onThumbsDown) && (
121
+ <>
122
+ {onThumbsUp && (
123
+ <Pressable
124
+ onPress={onThumbsUp}
125
+ style={({ pressed }) => [
126
+ tokenStyles.reactionBtn,
127
+ pressed && styles.pressed,
128
+ ]}
129
+ accessibilityRole="button"
130
+ accessibilityLabel="Thumbs up"
131
+ >
132
+ <Ionicons
133
+ name="thumbs-up-outline"
134
+ size={iconSize}
135
+ color={iconColor}
136
+ />
137
+ </Pressable>
138
+ )}
139
+ {onThumbsDown && (
140
+ <Pressable
141
+ onPress={onThumbsDown}
142
+ style={({ pressed }) => [
143
+ tokenStyles.reactionBtn,
144
+ pressed && styles.pressed,
145
+ ]}
146
+ accessibilityRole="button"
147
+ accessibilityLabel="Thumbs down"
148
+ >
149
+ <Ionicons
150
+ name="thumbs-down-outline"
151
+ size={iconSize}
152
+ color={iconColor}
153
+ />
154
+ </Pressable>
155
+ )}
156
+ </>
157
+ )}
158
+ </View>
159
+ </View>
160
+ </View>
161
+ );
162
+ }
163
+
164
+ const styles = StyleSheet.create({
165
+ row: {
166
+ flexDirection: 'row',
167
+ width: '100%',
168
+ },
169
+ rowIncoming: {
170
+ justifyContent: 'flex-start',
171
+ },
172
+ rowOutgoing: {
173
+ justifyContent: 'flex-end',
174
+ },
175
+ metaIncoming: {
176
+ justifyContent: 'flex-start',
177
+ },
178
+ metaOutgoing: {
179
+ justifyContent: 'flex-end',
180
+ },
181
+ messageText: {
182
+ flexShrink: 1,
183
+ },
184
+ pressed: {
185
+ opacity: 0.6,
186
+ },
187
+ });