@astacinco/rn-primitives 0.1.0 → 0.2.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.
- package/README.md +195 -0
- package/__tests__/Tabs.test.tsx +194 -0
- package/__tests__/Tag.test.tsx +123 -0
- package/__tests__/Timer.test.tsx +208 -0
- package/package.json +10 -6
- package/src/AppFooter/AppFooter.tsx +113 -0
- package/src/AppFooter/index.ts +2 -0
- package/src/AppFooter/types.ts +39 -0
- package/src/AppHeader/AppHeader.tsx +165 -0
- package/src/AppHeader/index.ts +2 -0
- package/src/AppHeader/types.ts +82 -0
- package/src/Avatar/Avatar.tsx +111 -0
- package/src/Avatar/index.ts +2 -0
- package/src/Avatar/types.ts +63 -0
- package/src/Badge/Badge.tsx +150 -0
- package/src/Badge/index.ts +2 -0
- package/src/Badge/types.ts +93 -0
- package/src/Button/Button.tsx +34 -20
- package/src/Button/types.ts +1 -1
- package/src/FloatingTierBadge/FloatingTierBadge.tsx +100 -0
- package/src/FloatingTierBadge/index.ts +2 -0
- package/src/FloatingTierBadge/types.ts +29 -0
- package/src/Input/Input.tsx +8 -23
- package/src/MarkdownViewer/MarkdownViewer.tsx +185 -0
- package/src/MarkdownViewer/index.ts +2 -0
- package/src/MarkdownViewer/types.ts +18 -0
- package/src/Modal/Modal.tsx +136 -0
- package/src/Modal/index.ts +2 -0
- package/src/Modal/types.ts +68 -0
- package/src/ProBadge/ProBadge.tsx +59 -0
- package/src/ProBadge/index.ts +2 -0
- package/src/ProBadge/types.ts +13 -0
- package/src/ProLockOverlay/ProLockOverlay.tsx +106 -0
- package/src/ProLockOverlay/index.ts +2 -0
- package/src/ProLockOverlay/types.ts +22 -0
- package/src/Switch/Switch.tsx +120 -0
- package/src/Switch/index.ts +2 -0
- package/src/Switch/types.ts +58 -0
- package/src/Tabs/Tabs.tsx +137 -0
- package/src/Tabs/index.ts +2 -0
- package/src/Tabs/types.ts +66 -0
- package/src/Tag/Tag.tsx +100 -0
- package/src/Tag/index.ts +2 -0
- package/src/Tag/types.ts +42 -0
- package/src/Timer/Timer.tsx +170 -0
- package/src/Timer/index.ts +2 -0
- package/src/Timer/types.ts +69 -0
- package/src/index.ts +52 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, act } from '@testing-library/react-native';
|
|
3
|
+
import { renderWithTheme, createThemeSnapshot } from '@astacinco/rn-testing';
|
|
4
|
+
import { Timer } from '../src/Timer';
|
|
5
|
+
|
|
6
|
+
describe('Timer', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
jest.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
jest.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Snapshot tests for both themes
|
|
16
|
+
createThemeSnapshot(<Timer durationMinutes={5} testID="timer" />);
|
|
17
|
+
|
|
18
|
+
describe('rendering', () => {
|
|
19
|
+
it('renders_initial_time', () => {
|
|
20
|
+
const { getByText } = renderWithTheme(
|
|
21
|
+
<Timer durationMinutes={5} testID="timer" />
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(getByText('05:00')).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders_controls_by_default', () => {
|
|
28
|
+
const { getByText } = renderWithTheme(
|
|
29
|
+
<Timer durationMinutes={5} showControls testID="timer" />
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(getByText('Start')).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('hides_controls_when_showControls_false', () => {
|
|
36
|
+
const { queryByText } = renderWithTheme(
|
|
37
|
+
<Timer durationMinutes={5} showControls={false} testID="timer" />
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(queryByText('Start')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('timer_display', () => {
|
|
45
|
+
it('shows_correct_format_for_minutes', () => {
|
|
46
|
+
const { getByText } = renderWithTheme(
|
|
47
|
+
<Timer durationMinutes={90} testID="timer" />
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(getByText('90:00')).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows_correct_format_for_1_minute', () => {
|
|
54
|
+
const { getByText } = renderWithTheme(
|
|
55
|
+
<Timer durationMinutes={1} testID="timer" />
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(getByText('01:00')).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('controls', () => {
|
|
63
|
+
it('shows_Start_button_initially', () => {
|
|
64
|
+
const { getByText } = renderWithTheme(
|
|
65
|
+
<Timer durationMinutes={5} showControls testID="timer" />
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(getByText('Start')).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('shows_Pause_button_when_running', () => {
|
|
72
|
+
const { getByText } = renderWithTheme(
|
|
73
|
+
<Timer durationMinutes={5} showControls testID="timer" />
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
fireEvent.press(getByText('Start'));
|
|
77
|
+
expect(getByText('Pause')).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows_Resume_button_when_paused', () => {
|
|
81
|
+
const { getByText } = renderWithTheme(
|
|
82
|
+
<Timer durationMinutes={5} showControls testID="timer" />
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
fireEvent.press(getByText('Start'));
|
|
86
|
+
fireEvent.press(getByText('Pause'));
|
|
87
|
+
expect(getByText('Resume')).toBeTruthy();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('shows_Reset_button_when_not_idle', () => {
|
|
91
|
+
const { getByText } = renderWithTheme(
|
|
92
|
+
<Timer durationMinutes={5} showControls testID="timer" />
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
fireEvent.press(getByText('Start'));
|
|
96
|
+
expect(getByText('Reset')).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('callbacks', () => {
|
|
101
|
+
it('calls_onStart_when_started', () => {
|
|
102
|
+
const mockOnStart = jest.fn();
|
|
103
|
+
const { getByText } = renderWithTheme(
|
|
104
|
+
<Timer durationMinutes={5} onStart={mockOnStart} showControls testID="timer" />
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
fireEvent.press(getByText('Start'));
|
|
108
|
+
expect(mockOnStart).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('calls_onPause_when_paused', () => {
|
|
112
|
+
const mockOnPause = jest.fn();
|
|
113
|
+
const { getByText } = renderWithTheme(
|
|
114
|
+
<Timer durationMinutes={5} onPause={mockOnPause} showControls testID="timer" />
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
fireEvent.press(getByText('Start'));
|
|
118
|
+
fireEvent.press(getByText('Pause'));
|
|
119
|
+
expect(mockOnPause).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('calls_onReset_when_reset', () => {
|
|
123
|
+
const mockOnReset = jest.fn();
|
|
124
|
+
const { getByText } = renderWithTheme(
|
|
125
|
+
<Timer durationMinutes={5} onReset={mockOnReset} showControls testID="timer" />
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
fireEvent.press(getByText('Start'));
|
|
129
|
+
fireEvent.press(getByText('Reset'));
|
|
130
|
+
expect(mockOnReset).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('countdown', () => {
|
|
135
|
+
it('counts_down_when_running', () => {
|
|
136
|
+
const { getByText } = renderWithTheme(
|
|
137
|
+
<Timer durationMinutes={1} showControls testID="timer" />
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
fireEvent.press(getByText('Start'));
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
jest.advanceTimersByTime(1000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(getByText('00:59')).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('resets_to_initial_time_when_reset', () => {
|
|
150
|
+
const { getByText } = renderWithTheme(
|
|
151
|
+
<Timer durationMinutes={1} showControls testID="timer" />
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
fireEvent.press(getByText('Start'));
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
jest.advanceTimersByTime(5000);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
fireEvent.press(getByText('Reset'));
|
|
161
|
+
expect(getByText('01:00')).toBeTruthy();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('autoStart', () => {
|
|
166
|
+
it('starts_automatically_when_autoStart_true', () => {
|
|
167
|
+
const mockOnStart = jest.fn();
|
|
168
|
+
const { getByText } = renderWithTheme(
|
|
169
|
+
<Timer
|
|
170
|
+
durationMinutes={1}
|
|
171
|
+
autoStart
|
|
172
|
+
onStart={mockOnStart}
|
|
173
|
+
showControls
|
|
174
|
+
testID="timer"
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(mockOnStart).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(getByText('Pause')).toBeTruthy();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('progress', () => {
|
|
184
|
+
it('shows_progress_when_showProgress_true', () => {
|
|
185
|
+
const { getByTestId } = renderWithTheme(
|
|
186
|
+
<Timer durationMinutes={5} showProgress testID="timer" />
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(getByTestId('timer')).toBeTruthy();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('theming', () => {
|
|
194
|
+
it('uses_different_colors_inDarkMode', () => {
|
|
195
|
+
const lightResult = renderWithTheme(
|
|
196
|
+
<Timer durationMinutes={5} testID="timer" />,
|
|
197
|
+
'light'
|
|
198
|
+
);
|
|
199
|
+
const darkResult = renderWithTheme(
|
|
200
|
+
<Timer durationMinutes={5} testID="timer" />,
|
|
201
|
+
'dark'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(lightResult.getByTestId('timer')).toBeTruthy();
|
|
205
|
+
expect(darkResult.getByTestId('timer')).toBeTruthy();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astacinco/rn-primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Theme-aware UI primitives for React Native",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": ">=18.0.0",
|
|
24
|
-
"react-native": ">=0.72.0"
|
|
24
|
+
"react-native": ">=0.72.0",
|
|
25
|
+
"react-native-markdown-display": ">=7.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"react-native-markdown-display": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
25
31
|
},
|
|
26
32
|
"dependencies": {
|
|
27
33
|
"@astacinco/rn-theming": "*"
|
|
@@ -29,10 +35,8 @@
|
|
|
29
35
|
"devDependencies": {
|
|
30
36
|
"@astacinco/rn-testing": "*",
|
|
31
37
|
"@testing-library/react-native": "^12.4.3",
|
|
32
|
-
"@types/react": "
|
|
33
|
-
"react": "
|
|
34
|
-
"react-native": "0.73.4",
|
|
35
|
-
"react-test-renderer": "18.2.0",
|
|
38
|
+
"@types/react": "~19.1.0",
|
|
39
|
+
"react-test-renderer": "19.1.0",
|
|
36
40
|
"typescript": "^5.3.3"
|
|
37
41
|
},
|
|
38
42
|
"keywords": [
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, Pressable, Linking } from 'react-native';
|
|
3
|
+
import { useTheme } from '@astacinco/rn-theming';
|
|
4
|
+
import { Text } from '../Text';
|
|
5
|
+
import { HStack } from '../Stack';
|
|
6
|
+
import type { AppFooterProps } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AppFooter - Consistent footer across apps
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - SparkLabs branding with Patreon link
|
|
13
|
+
* - Social links (GitHub, YouTube, etc.)
|
|
14
|
+
* - Copyright
|
|
15
|
+
* - Optional version info
|
|
16
|
+
*/
|
|
17
|
+
export function AppFooter({
|
|
18
|
+
showPatreonLink = true,
|
|
19
|
+
showGitHub = true,
|
|
20
|
+
showCopyright = true,
|
|
21
|
+
version,
|
|
22
|
+
customLinks,
|
|
23
|
+
testID,
|
|
24
|
+
}: AppFooterProps) {
|
|
25
|
+
const { colors, spacing } = useTheme();
|
|
26
|
+
|
|
27
|
+
const handleLink = (url: string) => {
|
|
28
|
+
Linking.openURL(url);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View
|
|
33
|
+
testID={testID}
|
|
34
|
+
style={[
|
|
35
|
+
styles.container,
|
|
36
|
+
{
|
|
37
|
+
backgroundColor: colors.surface,
|
|
38
|
+
borderTopColor: colors.border,
|
|
39
|
+
paddingHorizontal: spacing.md,
|
|
40
|
+
paddingVertical: spacing.md,
|
|
41
|
+
},
|
|
42
|
+
]}
|
|
43
|
+
>
|
|
44
|
+
{/* Links Row */}
|
|
45
|
+
<HStack justify="center" spacing="lg" style={styles.linksRow}>
|
|
46
|
+
{showPatreonLink && (
|
|
47
|
+
<Pressable onPress={() => handleLink('https://patreon.com/SparkLabs343')}>
|
|
48
|
+
<Text variant="caption" color={colors.primary}>
|
|
49
|
+
⚡ SparkLabs343
|
|
50
|
+
</Text>
|
|
51
|
+
</Pressable>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
{showGitHub && (
|
|
55
|
+
<Pressable onPress={() => handleLink('https://github.com/jrudydev')}>
|
|
56
|
+
<Text variant="caption" color={colors.textSecondary}>
|
|
57
|
+
GitHub
|
|
58
|
+
</Text>
|
|
59
|
+
</Pressable>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{customLinks?.map((link, index) => (
|
|
63
|
+
<Pressable key={index} onPress={() => handleLink(link.url)}>
|
|
64
|
+
<Text variant="caption" color={colors.textSecondary}>
|
|
65
|
+
{link.label}
|
|
66
|
+
</Text>
|
|
67
|
+
</Pressable>
|
|
68
|
+
))}
|
|
69
|
+
</HStack>
|
|
70
|
+
|
|
71
|
+
{/* Bottom Row */}
|
|
72
|
+
<HStack justify="center" spacing="sm" style={styles.bottomRow}>
|
|
73
|
+
{showCopyright && (
|
|
74
|
+
<Text variant="caption" color={colors.textMuted}>
|
|
75
|
+
© {new Date().getFullYear()} Rudy Gomez
|
|
76
|
+
</Text>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{version && (
|
|
80
|
+
<Text variant="caption" color={colors.textMuted}>
|
|
81
|
+
· v{version}
|
|
82
|
+
</Text>
|
|
83
|
+
)}
|
|
84
|
+
</HStack>
|
|
85
|
+
|
|
86
|
+
{/* Powered by */}
|
|
87
|
+
<View style={styles.poweredBy}>
|
|
88
|
+
<Text variant="caption" color={colors.textMuted} style={styles.poweredByText}>
|
|
89
|
+
Built with @astacinco packages
|
|
90
|
+
</Text>
|
|
91
|
+
</View>
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
container: {
|
|
98
|
+
borderTopWidth: 1,
|
|
99
|
+
},
|
|
100
|
+
linksRow: {
|
|
101
|
+
marginBottom: 8,
|
|
102
|
+
},
|
|
103
|
+
bottomRow: {
|
|
104
|
+
marginBottom: 4,
|
|
105
|
+
},
|
|
106
|
+
poweredBy: {
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
},
|
|
109
|
+
poweredByText: {
|
|
110
|
+
fontSize: 10,
|
|
111
|
+
letterSpacing: 1,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface FooterLink {
|
|
2
|
+
label: string;
|
|
3
|
+
url: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AppFooterProps {
|
|
7
|
+
/**
|
|
8
|
+
* Show Patreon/SparkLabs link
|
|
9
|
+
* @default true
|
|
10
|
+
*/
|
|
11
|
+
showPatreonLink?: boolean;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Show GitHub link
|
|
15
|
+
* @default true
|
|
16
|
+
*/
|
|
17
|
+
showGitHub?: boolean;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Show copyright
|
|
21
|
+
* @default true
|
|
22
|
+
*/
|
|
23
|
+
showCopyright?: boolean;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* App version to display
|
|
27
|
+
*/
|
|
28
|
+
version?: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Custom links to display
|
|
32
|
+
*/
|
|
33
|
+
customLinks?: FooterLink[];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Test ID for testing
|
|
37
|
+
*/
|
|
38
|
+
testID?: string;
|
|
39
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, Pressable } from 'react-native';
|
|
3
|
+
import { useTheme } from '@astacinco/rn-theming';
|
|
4
|
+
import { Text } from '../Text';
|
|
5
|
+
import { HStack } from '../Stack';
|
|
6
|
+
import { Avatar } from '../Avatar';
|
|
7
|
+
import type { AppHeaderProps } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* AppHeader - Consistent header across apps
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - App title with optional glow effect
|
|
14
|
+
* - Theme variant toggle (default/sparklabs)
|
|
15
|
+
* - Theme mode toggle (light/dark)
|
|
16
|
+
* - Optional profile button (top right)
|
|
17
|
+
* - Optional custom actions
|
|
18
|
+
*/
|
|
19
|
+
export function AppHeader({
|
|
20
|
+
title = 'SparkLabs',
|
|
21
|
+
subtitle,
|
|
22
|
+
showThemeToggle = true,
|
|
23
|
+
showThemeVariant = false,
|
|
24
|
+
themeVariant = 'default',
|
|
25
|
+
onThemeVariantChange,
|
|
26
|
+
onThemeChange,
|
|
27
|
+
showProfile = false,
|
|
28
|
+
profileImageUrl,
|
|
29
|
+
profileFallback = '?',
|
|
30
|
+
onProfilePress,
|
|
31
|
+
glow = false,
|
|
32
|
+
actions,
|
|
33
|
+
testID,
|
|
34
|
+
}: AppHeaderProps) {
|
|
35
|
+
const { colors, mode, setMode, spacing } = useTheme();
|
|
36
|
+
|
|
37
|
+
const handleThemeToggle = () => {
|
|
38
|
+
const newMode = mode === 'light' ? 'dark' : 'light';
|
|
39
|
+
setMode(newMode);
|
|
40
|
+
onThemeChange?.(newMode);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleThemeVariantToggle = () => {
|
|
44
|
+
const newVariant = themeVariant === 'default' ? 'sparklabs' : 'default';
|
|
45
|
+
onThemeVariantChange?.(newVariant);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const glowStyle = glow && mode === 'dark' ? {
|
|
49
|
+
textShadowColor: colors.primary,
|
|
50
|
+
textShadowOffset: { width: 0, height: 0 },
|
|
51
|
+
textShadowRadius: 10,
|
|
52
|
+
} : {};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
testID={testID}
|
|
57
|
+
style={[
|
|
58
|
+
styles.container,
|
|
59
|
+
{
|
|
60
|
+
backgroundColor: colors.surface,
|
|
61
|
+
borderBottomColor: colors.border,
|
|
62
|
+
paddingHorizontal: spacing.md,
|
|
63
|
+
paddingVertical: spacing.sm,
|
|
64
|
+
},
|
|
65
|
+
]}
|
|
66
|
+
>
|
|
67
|
+
<HStack justify="space-between" align="center">
|
|
68
|
+
{/* Left: Title */}
|
|
69
|
+
<View style={styles.titleContainer}>
|
|
70
|
+
<Text
|
|
71
|
+
variant="subtitle"
|
|
72
|
+
style={[
|
|
73
|
+
styles.title,
|
|
74
|
+
glowStyle,
|
|
75
|
+
{ color: glow && mode === 'dark' ? colors.primary : colors.text },
|
|
76
|
+
]}
|
|
77
|
+
>
|
|
78
|
+
{title}
|
|
79
|
+
</Text>
|
|
80
|
+
{subtitle && (
|
|
81
|
+
<Text variant="caption" color={colors.textMuted}>
|
|
82
|
+
{subtitle}
|
|
83
|
+
</Text>
|
|
84
|
+
)}
|
|
85
|
+
</View>
|
|
86
|
+
|
|
87
|
+
{/* Right: Actions */}
|
|
88
|
+
<HStack spacing="sm" align="center">
|
|
89
|
+
{/* Custom actions */}
|
|
90
|
+
{actions}
|
|
91
|
+
|
|
92
|
+
{/* Theme variant toggle */}
|
|
93
|
+
{showThemeVariant && (
|
|
94
|
+
<Pressable
|
|
95
|
+
onPress={handleThemeVariantToggle}
|
|
96
|
+
style={({ pressed }) => [
|
|
97
|
+
styles.iconButton,
|
|
98
|
+
{
|
|
99
|
+
backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
|
|
100
|
+
borderColor: colors.border,
|
|
101
|
+
},
|
|
102
|
+
]}
|
|
103
|
+
accessibilityLabel={`Switch to ${themeVariant === 'default' ? 'SparkLabs' : 'Default'} theme`}
|
|
104
|
+
accessibilityRole="button"
|
|
105
|
+
>
|
|
106
|
+
<Text variant="caption" color={colors.primary}>
|
|
107
|
+
{themeVariant === 'default' ? '✨ Spark' : '🎨 Default'}
|
|
108
|
+
</Text>
|
|
109
|
+
</Pressable>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* Light/dark mode toggle */}
|
|
113
|
+
{showThemeToggle && (
|
|
114
|
+
<Pressable
|
|
115
|
+
onPress={handleThemeToggle}
|
|
116
|
+
style={({ pressed }) => [
|
|
117
|
+
styles.iconButton,
|
|
118
|
+
{
|
|
119
|
+
backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
|
|
120
|
+
borderColor: colors.border,
|
|
121
|
+
},
|
|
122
|
+
]}
|
|
123
|
+
accessibilityLabel={`Switch to ${mode === 'light' ? 'dark' : 'light'} mode`}
|
|
124
|
+
accessibilityRole="button"
|
|
125
|
+
>
|
|
126
|
+
<Text variant="body">
|
|
127
|
+
{mode === 'light' ? '🌙' : '☀️'}
|
|
128
|
+
</Text>
|
|
129
|
+
</Pressable>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* Profile button */}
|
|
133
|
+
{showProfile && (
|
|
134
|
+
<Pressable onPress={onProfilePress}>
|
|
135
|
+
<Avatar
|
|
136
|
+
source={profileImageUrl ? { uri: profileImageUrl } : undefined}
|
|
137
|
+
fallback={profileFallback}
|
|
138
|
+
size="sm"
|
|
139
|
+
/>
|
|
140
|
+
</Pressable>
|
|
141
|
+
)}
|
|
142
|
+
</HStack>
|
|
143
|
+
</HStack>
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const styles = StyleSheet.create({
|
|
149
|
+
container: {
|
|
150
|
+
borderBottomWidth: 1,
|
|
151
|
+
},
|
|
152
|
+
titleContainer: {
|
|
153
|
+
flexDirection: 'column',
|
|
154
|
+
},
|
|
155
|
+
title: {
|
|
156
|
+
fontWeight: '700',
|
|
157
|
+
letterSpacing: 1,
|
|
158
|
+
},
|
|
159
|
+
iconButton: {
|
|
160
|
+
paddingHorizontal: 12,
|
|
161
|
+
paddingVertical: 8,
|
|
162
|
+
borderRadius: 6,
|
|
163
|
+
borderWidth: 1,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ResolvedThemeMode } from '@astacinco/rn-theming';
|
|
3
|
+
|
|
4
|
+
export type ThemeVariant = 'default' | 'sparklabs';
|
|
5
|
+
|
|
6
|
+
export interface AppHeaderProps {
|
|
7
|
+
/**
|
|
8
|
+
* App title displayed in the header
|
|
9
|
+
* @default 'SparkLabs'
|
|
10
|
+
*/
|
|
11
|
+
title?: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Optional subtitle below the title
|
|
15
|
+
*/
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Show light/dark mode toggle
|
|
20
|
+
* @default true
|
|
21
|
+
*/
|
|
22
|
+
showThemeToggle?: boolean;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Show theme variant toggle (default/sparklabs)
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
showThemeVariant?: boolean;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Current theme variant (required if showThemeVariant is true)
|
|
32
|
+
*/
|
|
33
|
+
themeVariant?: ThemeVariant;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Callback when theme variant changes
|
|
37
|
+
*/
|
|
38
|
+
onThemeVariantChange?: (variant: ThemeVariant) => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Callback when theme mode changes
|
|
42
|
+
*/
|
|
43
|
+
onThemeChange?: (mode: ResolvedThemeMode) => void;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Show profile button (top right)
|
|
47
|
+
* @default false
|
|
48
|
+
*/
|
|
49
|
+
showProfile?: boolean;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Profile button image URL (optional, shows initials/? if not provided)
|
|
53
|
+
*/
|
|
54
|
+
profileImageUrl?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Profile fallback text (initials or ?)
|
|
58
|
+
* @default '?'
|
|
59
|
+
*/
|
|
60
|
+
profileFallback?: string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Callback when profile button is pressed
|
|
64
|
+
*/
|
|
65
|
+
onProfilePress?: () => void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enable glow effect on title (SparkLabs aesthetic)
|
|
69
|
+
* @default false
|
|
70
|
+
*/
|
|
71
|
+
glow?: boolean;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Custom action buttons to display (in addition to built-in actions)
|
|
75
|
+
*/
|
|
76
|
+
actions?: ReactNode;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Test ID for testing
|
|
80
|
+
*/
|
|
81
|
+
testID?: string;
|
|
82
|
+
}
|