expotesting1 4.1.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 +289 -0
- package/apps/expo-app/app.json +60 -0
- package/apps/expo-app/babel.config.js +7 -0
- package/apps/expo-app/index.js +6 -0
- package/apps/expo-app/package.json +46 -0
- package/apps/expo-app/src/App.jsx +37 -0
- package/apps/expo-app/src/navigation/RootNavigator.jsx +82 -0
- package/apps/expo-app/src/navigation/types.js +5 -0
- package/apps/expo-app/src/screens/HomeScreen.jsx +178 -0
- package/package.json +25 -0
- package/packages/animations/package.json +20 -0
- package/packages/animations/src/components/FadeView.jsx +42 -0
- package/packages/animations/src/components/ScaleView.jsx +28 -0
- package/packages/animations/src/components/SlideView.jsx +32 -0
- package/packages/animations/src/hooks/useFade.js +50 -0
- package/packages/animations/src/hooks/useScale.js +59 -0
- package/packages/animations/src/hooks/useSlide.js +53 -0
- package/packages/animations/src/index.js +21 -0
- package/packages/animations/src/reanimated.js +83 -0
- package/packages/core/package.json +22 -0
- package/packages/core/src/components/Button.jsx +92 -0
- package/packages/core/src/components/Card.jsx +47 -0
- package/packages/core/src/components/Container.jsx +61 -0
- package/packages/core/src/components/Input.jsx +83 -0
- package/packages/core/src/components/List.jsx +80 -0
- package/packages/core/src/components/index.js +9 -0
- package/packages/core/src/hooks/index.js +5 -0
- package/packages/core/src/hooks/useAsync.js +60 -0
- package/packages/core/src/hooks/useCounter.js +36 -0
- package/packages/core/src/hooks/useToggle.js +18 -0
- package/packages/core/src/index.js +5 -0
- package/packages/core/src/theme/index.js +67 -0
- package/packages/core/src/utils/helpers.js +93 -0
- package/packages/core/src/utils/index.js +10 -0
- package/packages/device/package.json +24 -0
- package/packages/device/src/hooks/useCamera.js +45 -0
- package/packages/device/src/hooks/useGallery.js +70 -0
- package/packages/device/src/hooks/useLocation.js +99 -0
- package/packages/device/src/index.js +5 -0
- package/packages/examples/package.json +36 -0
- package/packages/examples/src/experiments/animations-device/AnimationsDeviceScreen.jsx +291 -0
- package/packages/examples/src/experiments/basic-app/BasicAppScreen.jsx +162 -0
- package/packages/examples/src/experiments/components-props-state/ComponentsStateScreen.jsx +280 -0
- package/packages/examples/src/experiments/navigation/NavigationScreen.jsx +202 -0
- package/packages/examples/src/experiments/network-storage/NetworkStorageScreen.jsx +367 -0
- package/packages/examples/src/experiments/state-management/StateManagementScreen.jsx +255 -0
- package/packages/examples/src/index.js +76 -0
- package/packages/navigation/package.json +20 -0
- package/packages/navigation/src/DrawerNavigator.jsx +35 -0
- package/packages/navigation/src/StackNavigator.jsx +51 -0
- package/packages/navigation/src/TabNavigator.jsx +44 -0
- package/packages/navigation/src/createAppNavigator.jsx +48 -0
- package/packages/navigation/src/index.js +8 -0
- package/packages/navigation/src/types.js +18 -0
- package/packages/network/package.json +19 -0
- package/packages/network/src/apiClient.js +90 -0
- package/packages/network/src/fetchHelpers.js +97 -0
- package/packages/network/src/hooks/useFetch.js +56 -0
- package/packages/network/src/index.js +3 -0
- package/packages/network/src/types.js +4 -0
- package/packages/state/package.json +22 -0
- package/packages/state/src/context/AuthContext.jsx +94 -0
- package/packages/state/src/context/ThemeContext.jsx +79 -0
- package/packages/state/src/context/index.js +3 -0
- package/packages/state/src/index.js +5 -0
- package/packages/state/src/redux/hooks.js +12 -0
- package/packages/state/src/redux/index.js +7 -0
- package/packages/state/src/redux/slices/counterSlice.js +39 -0
- package/packages/state/src/redux/slices/postsSlice.js +92 -0
- package/packages/state/src/redux/store.js +32 -0
- package/packages/storage/package.json +24 -0
- package/packages/storage/src/asyncStorage.js +82 -0
- package/packages/storage/src/index.js +2 -0
- package/packages/storage/src/sqlite/database.js +65 -0
- package/packages/storage/src/sqlite/index.js +3 -0
- package/packages/storage/src/sqlite/operations.js +112 -0
- package/packages/storage/src/sqlite/useSQLite.js +45 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button — accessible pressable button with multiple visual variants.
|
|
3
|
+
*
|
|
4
|
+
* Variants: 'filled' | 'outlined' | 'ghost'
|
|
5
|
+
* Sizes: 'sm' | 'md' | 'lg'
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <Button onPress={handleSubmit} loading={submitting}>Submit</Button>
|
|
9
|
+
* <Button variant="outlined" size="sm" onPress={cancel}>Cancel</Button>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import {
|
|
14
|
+
ActivityIndicator,
|
|
15
|
+
Pressable,
|
|
16
|
+
StyleSheet,
|
|
17
|
+
Text } from 'react-native';
|
|
18
|
+
import { palette, radius, spacing, typography } from '../theme';
|
|
19
|
+
|
|
20
|
+
export function Button({
|
|
21
|
+
variant = 'filled',
|
|
22
|
+
size = 'md',
|
|
23
|
+
loading = false,
|
|
24
|
+
fullWidth = false,
|
|
25
|
+
disabled,
|
|
26
|
+
style,
|
|
27
|
+
labelStyle,
|
|
28
|
+
children,
|
|
29
|
+
...rest
|
|
30
|
+
}){
|
|
31
|
+
const isDisabled = disabled || loading;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Pressable
|
|
35
|
+
style={({ pressed }) => [
|
|
36
|
+
styles.base,
|
|
37
|
+
sizeStyles[size],
|
|
38
|
+
variantStyles[variant].button,
|
|
39
|
+
isDisabled && styles.disabled,
|
|
40
|
+
fullWidth && styles.fullWidth,
|
|
41
|
+
pressed && !isDisabled && styles.pressed,
|
|
42
|
+
style,
|
|
43
|
+
]}
|
|
44
|
+
disabled={isDisabled}
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
|
47
|
+
{...rest}
|
|
48
|
+
>
|
|
49
|
+
{loading ? (
|
|
50
|
+
<ActivityIndicator
|
|
51
|
+
size="small"
|
|
52
|
+
color={variant === 'filled' ? palette.onPrimary : palette.primary}
|
|
53
|
+
/>
|
|
54
|
+
) : (
|
|
55
|
+
<Text style={[styles.label, variantStyles[variant].label, labelStyle]}>
|
|
56
|
+
{children}
|
|
57
|
+
</Text>
|
|
58
|
+
)}
|
|
59
|
+
</Pressable>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sizeStyles= {
|
|
64
|
+
sm: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm, minHeight: 36 },
|
|
65
|
+
md: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md, minHeight: 44 },
|
|
66
|
+
lg: { paddingVertical: spacing.md, paddingHorizontal: spacing.lg, minHeight: 52 } };
|
|
67
|
+
|
|
68
|
+
const variantStyles = {
|
|
69
|
+
filled: {
|
|
70
|
+
button: { backgroundColor: palette.primary, borderWidth: 0 },
|
|
71
|
+
label: { color: palette.onPrimary } },
|
|
72
|
+
outlined: {
|
|
73
|
+
button: { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: palette.primary },
|
|
74
|
+
label: { color: palette.primary } },
|
|
75
|
+
ghost: {
|
|
76
|
+
button: { backgroundColor: 'transparent', borderWidth: 0 },
|
|
77
|
+
label: { color: palette.primary } } };
|
|
78
|
+
|
|
79
|
+
const styles = StyleSheet.create({
|
|
80
|
+
base: {
|
|
81
|
+
borderRadius: radius.sm,
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
flexDirection: 'row' },
|
|
85
|
+
label: {
|
|
86
|
+
...typography.button },
|
|
87
|
+
disabled: {
|
|
88
|
+
opacity: 0.4 },
|
|
89
|
+
pressed: {
|
|
90
|
+
opacity: 0.8 },
|
|
91
|
+
fullWidth: {
|
|
92
|
+
width: '100%' } });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card — elevated surface for grouping related content.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* <Card title="Profile">
|
|
6
|
+
* <Text>Content here</Text>
|
|
7
|
+
* </Card>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
12
|
+
import { palette, radius, shadows, spacing, typography } from '../theme';
|
|
13
|
+
|
|
14
|
+
export function Card({
|
|
15
|
+
title,
|
|
16
|
+
subtitle,
|
|
17
|
+
elevation = 'sm',
|
|
18
|
+
style,
|
|
19
|
+
children }){
|
|
20
|
+
return (
|
|
21
|
+
<View style={[styles.card, elevation !== 'none' && shadows[elevation], style]}>
|
|
22
|
+
{(title || subtitle) && (
|
|
23
|
+
<View style={styles.header}>
|
|
24
|
+
{title && <Text style={styles.title}>{title}</Text>}
|
|
25
|
+
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
|
26
|
+
</View>
|
|
27
|
+
)}
|
|
28
|
+
{children}
|
|
29
|
+
</View>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const styles = StyleSheet.create({
|
|
34
|
+
card: {
|
|
35
|
+
backgroundColor: palette.surface,
|
|
36
|
+
borderRadius: radius.md,
|
|
37
|
+
padding: spacing.md,
|
|
38
|
+
marginVertical: spacing.sm },
|
|
39
|
+
header: {
|
|
40
|
+
marginBottom: spacing.sm },
|
|
41
|
+
title: {
|
|
42
|
+
...typography.h3,
|
|
43
|
+
color: palette.onSurface },
|
|
44
|
+
subtitle: {
|
|
45
|
+
...typography.caption,
|
|
46
|
+
color: palette.grey700,
|
|
47
|
+
marginTop: 2 } });
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container — full-width screen wrapper with consistent horizontal padding.
|
|
3
|
+
* Supports safe area awareness via `safe` prop (requires react-native-safe-area-context).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* <Container safe>
|
|
7
|
+
* <Text>Hello</Text>
|
|
8
|
+
* </Container>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import {
|
|
13
|
+
ScrollView,
|
|
14
|
+
StyleSheet,
|
|
15
|
+
View } from 'react-native';
|
|
16
|
+
import { spacing, palette } from '../theme';
|
|
17
|
+
|
|
18
|
+
export function Container({
|
|
19
|
+
scroll = false,
|
|
20
|
+
padded = true,
|
|
21
|
+
backgroundColor = palette.background,
|
|
22
|
+
style,
|
|
23
|
+
children,
|
|
24
|
+
scrollProps,
|
|
25
|
+
...rest
|
|
26
|
+
}){
|
|
27
|
+
const containerStyle = StyleSheet.flatten([
|
|
28
|
+
styles.base,
|
|
29
|
+
{ backgroundColor },
|
|
30
|
+
padded && styles.padded,
|
|
31
|
+
style,
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (scroll) {
|
|
35
|
+
return (
|
|
36
|
+
<ScrollView
|
|
37
|
+
style={[styles.base, { backgroundColor }]}
|
|
38
|
+
contentContainerStyle={[styles.scrollContent, padded && styles.padded]}
|
|
39
|
+
showsVerticalScrollIndicator={false}
|
|
40
|
+
{...scrollProps}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</ScrollView>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View style={containerStyle} {...rest}>
|
|
49
|
+
{children}
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const styles = StyleSheet.create({
|
|
55
|
+
base: {
|
|
56
|
+
flex: 1 },
|
|
57
|
+
padded: {
|
|
58
|
+
paddingHorizontal: spacing.md },
|
|
59
|
+
scrollContent: {
|
|
60
|
+
flexGrow: 1,
|
|
61
|
+
paddingBottom: spacing.xl } });
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input — labeled text input with error/helper text.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* <Input label="Email" value={email} onChangeText={setEmail} error={errors.email} />
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
StyleSheet,
|
|
11
|
+
Text,
|
|
12
|
+
TextInput,
|
|
13
|
+
View } from 'react-native';
|
|
14
|
+
import { palette, radius, spacing, typography } from '../theme';
|
|
15
|
+
|
|
16
|
+
export function Input({
|
|
17
|
+
label,
|
|
18
|
+
error,
|
|
19
|
+
helperText,
|
|
20
|
+
containerStyle,
|
|
21
|
+
style,
|
|
22
|
+
onFocus,
|
|
23
|
+
onBlur,
|
|
24
|
+
...rest
|
|
25
|
+
}){
|
|
26
|
+
const [focused, setFocused] = useState(false);
|
|
27
|
+
|
|
28
|
+
const borderColor = error
|
|
29
|
+
? palette.error
|
|
30
|
+
: focused
|
|
31
|
+
? palette.primary
|
|
32
|
+
: palette.grey400;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View style={[styles.container, containerStyle]}>
|
|
36
|
+
{label && <Text style={styles.label}>{label}</Text>}
|
|
37
|
+
<TextInput
|
|
38
|
+
style={[styles.input, { borderColor }, style]}
|
|
39
|
+
placeholderTextColor={palette.grey400}
|
|
40
|
+
onFocus={(e) => {
|
|
41
|
+
setFocused(true);
|
|
42
|
+
onFocus?.(e);
|
|
43
|
+
}}
|
|
44
|
+
onBlur={(e) => {
|
|
45
|
+
setFocused(false);
|
|
46
|
+
onBlur?.(e);
|
|
47
|
+
}}
|
|
48
|
+
accessibilityLabel={label}
|
|
49
|
+
{...rest}
|
|
50
|
+
/>
|
|
51
|
+
{error ? (
|
|
52
|
+
<Text style={styles.error}>{error}</Text>
|
|
53
|
+
) : helperText ? (
|
|
54
|
+
<Text style={styles.helper}>{helperText}</Text>
|
|
55
|
+
) : null}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const styles = StyleSheet.create({
|
|
61
|
+
container: {
|
|
62
|
+
marginVertical: spacing.xs },
|
|
63
|
+
label: {
|
|
64
|
+
...typography.caption,
|
|
65
|
+
color: palette.grey700,
|
|
66
|
+
marginBottom: 4,
|
|
67
|
+
fontWeight: '600' },
|
|
68
|
+
input: {
|
|
69
|
+
height: 48,
|
|
70
|
+
borderWidth: 1.5,
|
|
71
|
+
borderRadius: radius.sm,
|
|
72
|
+
paddingHorizontal: spacing.sm,
|
|
73
|
+
...typography.body,
|
|
74
|
+
color: palette.onBackground,
|
|
75
|
+
backgroundColor: palette.surface },
|
|
76
|
+
error: {
|
|
77
|
+
...typography.caption,
|
|
78
|
+
color: palette.error,
|
|
79
|
+
marginTop: 4 },
|
|
80
|
+
helper: {
|
|
81
|
+
...typography.caption,
|
|
82
|
+
color: palette.grey700,
|
|
83
|
+
marginTop: 4 } });
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List — typed FlatList wrapper with built-in empty/loading/error states.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* <List
|
|
6
|
+
* data={items}
|
|
7
|
+
* renderItem={({ item }) => <Text>{item.name}</Text>}
|
|
8
|
+
* keyExtractor={(item) => item.id}
|
|
9
|
+
* loading={isLoading}
|
|
10
|
+
* emptyMessage="No items found"
|
|
11
|
+
* />
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import {
|
|
16
|
+
ActivityIndicator,
|
|
17
|
+
FlatList,
|
|
18
|
+
StyleSheet,
|
|
19
|
+
Text,
|
|
20
|
+
View } from 'react-native';
|
|
21
|
+
import { palette, spacing, typography } from '../theme';
|
|
22
|
+
|
|
23
|
+
// Generic FlatList wrapper — must be a function component (not arrow) for generic JSX
|
|
24
|
+
export function List({
|
|
25
|
+
data,
|
|
26
|
+
loading,
|
|
27
|
+
error,
|
|
28
|
+
emptyMessage = 'No items.',
|
|
29
|
+
containerStyle,
|
|
30
|
+
...flatListProps
|
|
31
|
+
}){
|
|
32
|
+
if (loading) {
|
|
33
|
+
return (
|
|
34
|
+
<View style={styles.center}>
|
|
35
|
+
<ActivityIndicator size="large" color={palette.primary} />
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (error) {
|
|
41
|
+
return (
|
|
42
|
+
<View style={styles.center}>
|
|
43
|
+
<Text style={styles.errorText}>{error}</Text>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<FlatList
|
|
50
|
+
data={data ?? []}
|
|
51
|
+
style={containerStyle}
|
|
52
|
+
contentContainerStyle={(!data || data.length === 0) ? styles.emptyContent : undefined}
|
|
53
|
+
ListEmptyComponent={
|
|
54
|
+
<View style={styles.center}>
|
|
55
|
+
<Text style={styles.emptyText}>{emptyMessage}</Text>
|
|
56
|
+
</View>
|
|
57
|
+
}
|
|
58
|
+
showsVerticalScrollIndicator={false}
|
|
59
|
+
{...flatListProps}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const styles = StyleSheet.create({
|
|
65
|
+
center: {
|
|
66
|
+
flex: 1,
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
paddingVertical: spacing.xl },
|
|
70
|
+
emptyContent: {
|
|
71
|
+
flexGrow: 1 },
|
|
72
|
+
emptyText: {
|
|
73
|
+
...typography.body,
|
|
74
|
+
color: palette.grey700,
|
|
75
|
+
textAlign: 'center' },
|
|
76
|
+
errorText: {
|
|
77
|
+
...typography.body,
|
|
78
|
+
color: palette.error,
|
|
79
|
+
textAlign: 'center',
|
|
80
|
+
paddingHorizontal: spacing.md } });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAsync — wraps an async function with loading/error/data state.
|
|
3
|
+
* The function is automatically executed once on mount (unless lazy=true).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* const { data, loading, error, execute } = useAsync(() => fetchUser(id), [id]);
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
export function useAsync(
|
|
12
|
+
asyncFn,
|
|
13
|
+
options = {},
|
|
14
|
+
){
|
|
15
|
+
const { lazy = false } = options;
|
|
16
|
+
|
|
17
|
+
const [data, setData] = useState(null);
|
|
18
|
+
const [loading, setLoading] = useState(!lazy);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
|
|
21
|
+
// Track whether the component is still mounted to avoid state updates after unmount
|
|
22
|
+
const mountedRef = useRef(true);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
mountedRef.current = true;
|
|
26
|
+
return () => {
|
|
27
|
+
mountedRef.current = false;
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const execute = useCallback(async () => {
|
|
32
|
+
if (!mountedRef.current) return;
|
|
33
|
+
setLoading(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
try {
|
|
36
|
+
const result = await asyncFn();
|
|
37
|
+
if (mountedRef.current) {
|
|
38
|
+
setData(result);
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (mountedRef.current) {
|
|
42
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
if (mountedRef.current) {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
+
}, [asyncFn]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!lazy) {
|
|
54
|
+
void execute();
|
|
55
|
+
}
|
|
56
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
|
+
}, [lazy]);
|
|
58
|
+
|
|
59
|
+
return { data, loading, error, execute };
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCounter — numeric counter with bounded increment/decrement.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* const { count, increment, decrement, reset, set } = useCounter(0, 1);
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
export function useCounter(initial = 0, options = {}){
|
|
11
|
+
const { step = 1, min, max } = options;
|
|
12
|
+
const [count, setCount] = useState(initial);
|
|
13
|
+
|
|
14
|
+
const increment = useCallback(() => {
|
|
15
|
+
setCount((c) => (max !== undefined ? Math.min(max, c + step) : c + step));
|
|
16
|
+
}, [step, max]);
|
|
17
|
+
|
|
18
|
+
const decrement = useCallback(() => {
|
|
19
|
+
setCount((c) => (min !== undefined ? Math.max(min, c - step) : c - step));
|
|
20
|
+
}, [step, min]);
|
|
21
|
+
|
|
22
|
+
const reset = useCallback(() => setCount(initial), [initial]);
|
|
23
|
+
|
|
24
|
+
const set = useCallback(
|
|
25
|
+
(value) => {
|
|
26
|
+
const clamped =
|
|
27
|
+
min !== undefined && max !== undefined
|
|
28
|
+
? Math.min(max, Math.max(min, value))
|
|
29
|
+
: value;
|
|
30
|
+
setCount(clamped);
|
|
31
|
+
},
|
|
32
|
+
[min, max],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return { count, increment, decrement, reset, set };
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useToggle — boolean toggle state with named setters.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* const { value, toggle, setTrue, setFalse } = useToggle(false);
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
export function useToggle(initial = false){
|
|
11
|
+
const [value, setValue] = useState(initial);
|
|
12
|
+
|
|
13
|
+
const toggle = useCallback(() => setValue((v) => !v), []);
|
|
14
|
+
const setTrue = useCallback(() => setValue(true), []);
|
|
15
|
+
const setFalse = useCallback(() => setValue(false), []);
|
|
16
|
+
|
|
17
|
+
return { value, toggle, setTrue, setFalse };
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design tokens — extend or override via ThemeProvider in @expotesting/state.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const palette = {
|
|
6
|
+
primary: '#6200EE',
|
|
7
|
+
primaryVariant: '#3700B3',
|
|
8
|
+
secondary: '#03DAC6',
|
|
9
|
+
background: '#FFFFFF',
|
|
10
|
+
surface: '#FFFFFF',
|
|
11
|
+
error: '#B00020',
|
|
12
|
+
onPrimary: '#FFFFFF',
|
|
13
|
+
onSecondary: '#000000',
|
|
14
|
+
onBackground: '#000000',
|
|
15
|
+
onSurface: '#000000',
|
|
16
|
+
onError: '#FFFFFF',
|
|
17
|
+
// neutrals
|
|
18
|
+
grey100: '#F5F5F5',
|
|
19
|
+
grey200: '#EEEEEE',
|
|
20
|
+
grey400: '#BDBDBD',
|
|
21
|
+
grey700: '#616161',
|
|
22
|
+
grey900: '#212121' };
|
|
23
|
+
|
|
24
|
+
export const spacing = {
|
|
25
|
+
xs: 4,
|
|
26
|
+
sm: 8,
|
|
27
|
+
md: 16,
|
|
28
|
+
lg: 24,
|
|
29
|
+
xl: 32,
|
|
30
|
+
xxl: 48 };
|
|
31
|
+
|
|
32
|
+
export const radius = {
|
|
33
|
+
sm: 4,
|
|
34
|
+
md: 8,
|
|
35
|
+
lg: 16,
|
|
36
|
+
full: 9999 };
|
|
37
|
+
|
|
38
|
+
export const typography = {
|
|
39
|
+
h1: { fontSize: 32, fontWeight: '700', lineHeight: 40 },
|
|
40
|
+
h2: { fontSize: 24, fontWeight: '700', lineHeight: 32 },
|
|
41
|
+
h3: { fontSize: 20, fontWeight: '600', lineHeight: 28 },
|
|
42
|
+
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
|
|
43
|
+
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
|
|
44
|
+
button: { fontSize: 14, fontWeight: '600', letterSpacing: 0.5 } };
|
|
45
|
+
|
|
46
|
+
export const shadows = {
|
|
47
|
+
sm: {
|
|
48
|
+
shadowColor: '#000',
|
|
49
|
+
shadowOffset: { width: 0, height: 1 },
|
|
50
|
+
shadowOpacity: 0.1,
|
|
51
|
+
shadowRadius: 2,
|
|
52
|
+
elevation: 2 },
|
|
53
|
+
md: {
|
|
54
|
+
shadowColor: '#000',
|
|
55
|
+
shadowOffset: { width: 0, height: 2 },
|
|
56
|
+
shadowOpacity: 0.15,
|
|
57
|
+
shadowRadius: 4,
|
|
58
|
+
elevation: 4 },
|
|
59
|
+
lg: {
|
|
60
|
+
shadowColor: '#000',
|
|
61
|
+
shadowOffset: { width: 0, height: 4 },
|
|
62
|
+
shadowOpacity: 0.2,
|
|
63
|
+
shadowRadius: 8,
|
|
64
|
+
elevation: 8 } };
|
|
65
|
+
|
|
66
|
+
export const theme = { palette, spacing, radius, typography, shadows };
|
|
67
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Generic type guard — narrows `unknown` to a typed value. */
|
|
2
|
+
export function isError(value){
|
|
3
|
+
return value instanceof Error;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Safely parse JSON — returns null on failure instead of throwing. */
|
|
7
|
+
export function safeJsonParse(raw){
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Throttle a function — prevents it from being called more than once per `limit` ms.
|
|
17
|
+
* Useful for expensive event handlers (e.g., scroll listeners).
|
|
18
|
+
*/
|
|
19
|
+
export function throttle(
|
|
20
|
+
fn,
|
|
21
|
+
limit,
|
|
22
|
+
) {
|
|
23
|
+
let lastCall = 0;
|
|
24
|
+
return (...args) => {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
if (now - lastCall >= limit) {
|
|
27
|
+
lastCall = now;
|
|
28
|
+
fn(...args);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Debounce a function — delays execution until `wait` ms after the last call.
|
|
35
|
+
* Useful for search inputs, form validation, etc.
|
|
36
|
+
*/
|
|
37
|
+
export function debounce(
|
|
38
|
+
fn,
|
|
39
|
+
wait,
|
|
40
|
+
) {
|
|
41
|
+
let timer= null;
|
|
42
|
+
|
|
43
|
+
const debounced = (...args) => {
|
|
44
|
+
if (timer) clearTimeout(timer);
|
|
45
|
+
timer = setTimeout(() => fn(...args), wait);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
debounced.cancel = () => {
|
|
49
|
+
if (timer) {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
timer = null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return debounced;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Formats a date to a human-readable string (locale-aware). */
|
|
59
|
+
export function formatDate(date, locale = 'en-US'){
|
|
60
|
+
return new Intl.DateTimeFormat(locale, {
|
|
61
|
+
year: 'numeric',
|
|
62
|
+
month: 'short',
|
|
63
|
+
day: 'numeric' }).format(date);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Clamps a number between min and max. */
|
|
67
|
+
export function clamp(value, min, max){
|
|
68
|
+
return Math.min(max, Math.max(min, value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Generates a random alphanumeric ID (not cryptographically secure). */
|
|
72
|
+
export function generateId(length = 8){
|
|
73
|
+
return Math.random().toString(36).substring(2, 2 + length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Capitalises the first letter of a string. */
|
|
77
|
+
export function capitalise(str){
|
|
78
|
+
if (!str) return str;
|
|
79
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Groups an array of objects by a key. */
|
|
83
|
+
export function groupBy(
|
|
84
|
+
array,
|
|
85
|
+
key,
|
|
86
|
+
){
|
|
87
|
+
return array.reduce((acc, item) => {
|
|
88
|
+
const group = String(item[key]);
|
|
89
|
+
if (!acc[group]) acc[group] = [];
|
|
90
|
+
acc[group].push(item);
|
|
91
|
+
return acc;
|
|
92
|
+
}, {});
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@expotesting/device",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Camera, gallery, and location hooks using Expo APIs",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"react": ">=18",
|
|
13
|
+
"react-native": ">=0.73",
|
|
14
|
+
"expo-camera": ">=15",
|
|
15
|
+
"expo-image-picker": ">=15",
|
|
16
|
+
"expo-location": ">=17"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"expo-camera": "*",
|
|
20
|
+
"expo-image-picker": "*",
|
|
21
|
+
"expo-location": "*"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT"
|
|
24
|
+
}
|