Skip to content

Commit 5851103

Browse files
authored
feat(example): add theme system and reusable UI components (#342)
# Description Part 1 of 5 in the RN example-app overhaul chain for [MAGE-464](https://linear.app/klaviyo/issue/MAGE-464). Scaffolds the theme system (tokens for colors, spacing, typography, radii), a shared StyleSheet, and a palette of reusable interaction components (ActionButton, ProfileTextField, SectionHeader, ToggleButtons, Collapsible). Pure additive — nothing imports these yet; subsequent PRs in the chain wire them into the app. ## Due Diligence - [x] I have tested this on a simulator/emulator or a physical device, on iOS and Android (if applicable). - [ ] I have added sufficient unit/integration tests of my changes. - [ ] I have adjusted or added new test cases to team test docs, if applicable. - [x] I am confident these changes are implemented with feature parity across iOS and Android (if applicable). ## Release/Versioning Considerations - [x] \`Patch\` Contains internal changes or backwards-compatible bug fixes. - [ ] \`Minor\` Contains changes to the public API. - [ ] \`Major\` Contains **breaking** changes. - [ ] Contains readme or migration guide changes. - [ ] This is planned work for an upcoming release. Example-app-only scope. No SDK changes. ## Changelog / Code Overview | Area | Change | |------|--------| | \`example/src/theme.ts\` (new) | Theme tokens: colors, spacing, typography, border radii | | \`example/src/Styles.ts\` (new) | Shared StyleSheet referencing theme tokens | | \`example/src/components/ActionButton.tsx\` (new) | Primary/secondary/destructive button with disabled + row variants | | \`example/src/components/ProfileTextField.tsx\` (new) | Labeled text input with optional inline Set button | | \`example/src/components/SectionHeader.tsx\` (new) | Simple section header rendered by SectionList | | \`example/src/components/ToggleButtons.tsx\` (new) | Left/Right paired toggle (used for Register/Unregister flows) | | \`example/src/components/Collapsible.tsx\` (new) | Accordion wrapper with smooth LayoutAnimation expand/collapse | ## Test Plan - [x] Components typecheck and lint clean - [ ] Visual verification happens in PR #3 when the app wires them up ## Related Issues/Tickets Part of [MAGE-464](https://linear.app/klaviyo/issue/MAGE-464) **Chained PR series:** 1. **This PR** — theme + components 2. permission helpers + hooks 3. app shell, API coverage, env loading 4. native platform setup (iOS + Android Firebase push) 5. docs 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents f34e9f4 + aaf676d commit 5851103

7 files changed

Lines changed: 594 additions & 5 deletions

File tree

example/src/Styles.ts

Lines changed: 276 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,285 @@
1-
import { StyleSheet } from 'react-native';
1+
import { StyleSheet, Platform } from 'react-native';
2+
import { colors, spacing, typography, borderRadius } from './theme';
3+
4+
// Shared base styles for DRY
5+
const buttonBase = {
6+
paddingVertical: spacing.md - 2, // 14px
7+
paddingHorizontal: spacing.md,
8+
borderRadius: borderRadius.sm,
9+
alignItems: 'center' as const,
10+
justifyContent: 'center' as const,
11+
minHeight: 48,
12+
borderWidth: 1,
13+
borderColor: colors.primary,
14+
backgroundColor: colors.cardBackground,
15+
};
16+
17+
const rowContainerBase = {
18+
flexDirection: 'row' as const,
19+
marginHorizontal: -spacing.xs / 2, // Negative margin for spacing trick
20+
};
221

322
export const styles = StyleSheet.create({
23+
// Container styles
24+
// Keep centering here for backwards compatibility with the pre-overhaul App
25+
// that still ships on feat/example-app. The new sectioned App.tsx (PR 3)
26+
// replaces this with a top-aligned layout in a separate wrapper style.
427
container: {
528
flex: 1,
629
alignItems: 'center',
730
justifyContent: 'center',
31+
backgroundColor: colors.background,
32+
},
33+
scrollContent: {
34+
padding: spacing.md,
35+
paddingBottom: spacing.xl,
36+
},
37+
38+
// Section styles
39+
section: {
40+
backgroundColor: colors.cardBackground,
41+
borderRadius: borderRadius.md,
42+
padding: spacing.md,
43+
marginBottom: spacing.md,
44+
borderWidth: 1,
45+
borderColor: colors.border,
46+
// Add subtle shadow for depth
47+
...Platform.select({
48+
ios: {
49+
shadowColor: '#000',
50+
shadowOffset: { width: 0, height: 2 },
51+
shadowOpacity: 0.1,
52+
shadowRadius: 4,
53+
},
54+
android: {
55+
elevation: 2,
56+
},
57+
}),
58+
},
59+
sectionHeader: {
60+
marginBottom: spacing.md,
61+
paddingBottom: spacing.sm,
62+
borderBottomWidth: 1,
63+
borderBottomColor: colors.border,
64+
},
65+
sectionHeaderText: {
66+
...typography.sectionHeader,
67+
color: colors.text,
68+
},
69+
70+
// Profile text field styles (label + input + button row)
71+
profileFieldContainer: {
72+
marginBottom: spacing.md,
73+
},
74+
profileFieldRow: {
75+
flexDirection: 'row',
76+
alignItems: 'center',
77+
gap: spacing.sm,
78+
},
79+
profileFieldLabel: {
80+
...typography.label,
81+
color: colors.text,
82+
marginBottom: spacing.xs,
83+
},
84+
profileFieldInput: {
85+
flex: 1,
86+
borderWidth: 1,
87+
borderColor: colors.border,
88+
borderRadius: borderRadius.sm,
89+
padding: spacing.md - 4, // 12px
90+
...typography.body,
91+
color: colors.text,
92+
backgroundColor: colors.cardBackground,
93+
// Better height consistency across platforms
94+
minHeight: 44, // iOS recommended touch target
95+
},
96+
profileFieldButton: {
97+
backgroundColor: colors.primary,
98+
paddingVertical: spacing.md - 4, // 12px
99+
paddingHorizontal: spacing.md,
100+
borderRadius: borderRadius.sm,
101+
minWidth: 60,
102+
minHeight: 44, // Match input height
103+
alignItems: 'center',
104+
justifyContent: 'center',
105+
},
106+
profileFieldButtonDisabled: {
107+
backgroundColor: colors.disabled,
108+
opacity: 0.5,
109+
},
110+
profileFieldButtonText: {
111+
...typography.button,
112+
color: colors.buttonText,
113+
},
114+
115+
// Collapsible styles
116+
collapsibleContainer: {
117+
marginBottom: spacing.sm,
118+
},
119+
collapsibleHeader: {
120+
flexDirection: 'row',
121+
alignItems: 'center',
122+
justifyContent: 'space-between',
123+
paddingVertical: spacing.sm,
124+
paddingHorizontal: spacing.sm,
125+
backgroundColor: colors.background,
126+
borderRadius: borderRadius.sm,
127+
borderWidth: 1,
128+
borderColor: colors.border,
129+
},
130+
collapsibleTitle: {
131+
...typography.body,
132+
fontWeight: '600',
133+
color: colors.text,
134+
},
135+
collapsibleChevron: {
136+
...typography.body,
137+
color: colors.secondaryText,
138+
},
139+
collapsibleContent: {
140+
marginTop: spacing.sm,
141+
},
142+
143+
// Action button styles
144+
actionButtonRow: {
145+
...rowContainerBase,
146+
marginBottom: spacing.sm,
147+
},
148+
actionButton: {
149+
...buttonBase,
150+
marginBottom: spacing.sm,
151+
},
152+
actionButtonInRow: {
153+
flex: 1,
154+
marginHorizontal: spacing.xs / 2, // Half spacing on each side
155+
marginBottom: 0, // Remove bottom margin when in row
156+
},
157+
actionButtonDestructive: {
158+
borderColor: colors.destructive,
159+
},
160+
actionButtonDisabled: {
161+
borderColor: colors.border,
162+
backgroundColor: colors.disabledBackground,
163+
},
164+
actionButtonText: {
165+
...typography.button,
166+
color: colors.primary,
167+
},
168+
actionButtonTextDestructive: {
169+
color: colors.destructive,
170+
},
171+
actionButtonTextDisabled: {
172+
color: colors.secondaryText,
173+
},
174+
actionButtonWithTopSpacing: {
175+
marginTop: spacing.sm,
176+
},
177+
178+
// Toggle button styles
179+
toggleContainer: {
180+
...rowContainerBase,
181+
},
182+
toggleButton: {
183+
...buttonBase,
184+
flex: 1,
185+
marginHorizontal: spacing.xs / 2, // Half spacing on each side = full spacing between buttons
186+
},
187+
toggleButtonActive: {
188+
backgroundColor: colors.activeButtonTint,
189+
},
190+
toggleButtonText: {
191+
...typography.button,
192+
color: colors.secondaryText,
193+
},
194+
toggleButtonTextActive: {
195+
color: colors.primary,
196+
},
197+
toggleButtonDisabled: {
198+
borderColor: colors.border,
199+
backgroundColor: colors.disabledBackground,
200+
},
201+
toggleButtonTextDisabled: {
202+
color: colors.secondaryText,
203+
},
204+
205+
// Permission granted text styles
206+
permissionGrantedContainer: {
207+
paddingVertical: spacing.md,
208+
paddingHorizontal: spacing.md,
209+
backgroundColor: colors.cardBackground,
210+
borderWidth: 0,
211+
alignItems: 'center',
212+
marginBottom: spacing.sm,
213+
},
214+
permissionGrantedText: {
215+
...typography.body,
216+
color: colors.primary,
217+
fontWeight: '600',
218+
},
219+
220+
// Push token display styles
221+
pushTokenContainer: {
222+
backgroundColor: colors.disabledBackground,
223+
borderWidth: 1,
224+
borderColor: colors.border,
225+
borderRadius: borderRadius.sm,
226+
padding: spacing.md,
227+
marginBottom: spacing.sm,
228+
},
229+
pushTokenLabel: {
230+
...typography.label,
231+
color: colors.secondaryText,
232+
marginBottom: spacing.xs,
233+
},
234+
pushTokenText: {
235+
fontSize: 12, // Smaller for long tokens
236+
color: colors.text,
237+
fontFamily: Platform.select({
238+
ios: 'Courier',
239+
android: 'monospace',
240+
}),
241+
lineHeight: 18,
242+
},
243+
pushTokenEmpty: {
244+
color: colors.placeholderText,
245+
fontStyle: 'italic',
246+
},
247+
248+
// Permission status display styles
249+
permissionStatusContainer: {
250+
paddingVertical: spacing.sm,
251+
paddingHorizontal: spacing.md,
252+
backgroundColor: colors.disabledBackground,
253+
borderWidth: 1,
254+
borderColor: colors.border,
255+
borderRadius: borderRadius.sm,
256+
marginBottom: spacing.sm,
257+
},
258+
permissionStatusLabel: {
259+
...typography.body,
260+
color: colors.text,
261+
fontWeight: '500',
262+
},
263+
264+
// Warning message styles
265+
warningContainer: {
266+
backgroundColor: colors.warningBackground,
267+
borderWidth: 1,
268+
borderColor: colors.warningBorder,
269+
borderRadius: borderRadius.sm,
270+
padding: spacing.md,
271+
},
272+
warningText: {
273+
...typography.body,
274+
color: colors.warningText,
275+
fontWeight: '600',
276+
marginBottom: spacing.xs,
8277
},
9-
box: {
10-
width: 60,
11-
height: 60,
12-
marginVertical: 20,
278+
warningSubtext: {
279+
...typography.body,
280+
fontSize: 14,
281+
color: colors.warningText,
282+
marginLeft: spacing.sm,
283+
marginTop: spacing.xs / 2,
13284
},
14285
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import { TouchableOpacity, Text } from 'react-native';
3+
import { styles } from '../Styles';
4+
5+
interface ActionButtonProps {
6+
title: string;
7+
onPress: () => void;
8+
disabled?: boolean;
9+
destructive?: boolean;
10+
inRow?: boolean;
11+
withTopSpacing?: boolean;
12+
}
13+
14+
/**
15+
* Styled action button with optional destructive styling
16+
*/
17+
export const ActionButton: React.FC<ActionButtonProps> = ({
18+
title,
19+
onPress,
20+
disabled = false,
21+
destructive = false,
22+
inRow = false,
23+
withTopSpacing = false,
24+
}) => {
25+
return (
26+
<TouchableOpacity
27+
style={[
28+
styles.actionButton,
29+
inRow && styles.actionButtonInRow,
30+
destructive && styles.actionButtonDestructive,
31+
disabled && styles.actionButtonDisabled,
32+
withTopSpacing && styles.actionButtonWithTopSpacing,
33+
]}
34+
onPress={onPress}
35+
disabled={disabled}
36+
>
37+
<Text
38+
style={[
39+
styles.actionButtonText,
40+
destructive && styles.actionButtonTextDestructive,
41+
disabled && styles.actionButtonTextDisabled,
42+
]}
43+
>
44+
{title}
45+
</Text>
46+
</TouchableOpacity>
47+
);
48+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React, { useState } from 'react';
2+
import {
3+
View,
4+
Text,
5+
TouchableOpacity,
6+
LayoutAnimation,
7+
Platform,
8+
UIManager,
9+
} from 'react-native';
10+
import { styles } from '../Styles';
11+
12+
if (
13+
Platform.OS === 'android' &&
14+
UIManager.setLayoutAnimationEnabledExperimental
15+
) {
16+
UIManager.setLayoutAnimationEnabledExperimental(true);
17+
}
18+
19+
interface CollapsibleProps {
20+
title: string;
21+
initiallyExpanded?: boolean;
22+
children: React.ReactNode;
23+
}
24+
25+
export const Collapsible: React.FC<CollapsibleProps> = ({
26+
title,
27+
initiallyExpanded = false,
28+
children,
29+
}) => {
30+
const [expanded, setExpanded] = useState(initiallyExpanded);
31+
32+
const toggle = () => {
33+
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
34+
setExpanded((prev) => !prev);
35+
};
36+
37+
return (
38+
<View style={styles.collapsibleContainer}>
39+
<TouchableOpacity
40+
style={styles.collapsibleHeader}
41+
onPress={toggle}
42+
activeOpacity={0.7}
43+
>
44+
<Text style={styles.collapsibleTitle}>{title}</Text>
45+
<Text style={styles.collapsibleChevron}>{expanded ? '▾' : '▸'}</Text>
46+
</TouchableOpacity>
47+
{expanded && <View style={styles.collapsibleContent}>{children}</View>}
48+
</View>
49+
);
50+
};

0 commit comments

Comments
 (0)