expotesting2 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.
Files changed (77) hide show
  1. package/README.md +289 -0
  2. package/apps/expo-app/app.json +60 -0
  3. package/apps/expo-app/babel.config.js +7 -0
  4. package/apps/expo-app/index.js +6 -0
  5. package/apps/expo-app/package.json +46 -0
  6. package/apps/expo-app/src/App.jsx +37 -0
  7. package/apps/expo-app/src/navigation/RootNavigator.jsx +82 -0
  8. package/apps/expo-app/src/navigation/types.js +5 -0
  9. package/apps/expo-app/src/screens/HomeScreen.jsx +178 -0
  10. package/package.json +24 -0
  11. package/packages/animations/package.json +20 -0
  12. package/packages/animations/src/components/FadeView.jsx +42 -0
  13. package/packages/animations/src/components/ScaleView.jsx +28 -0
  14. package/packages/animations/src/components/SlideView.jsx +32 -0
  15. package/packages/animations/src/hooks/useFade.js +50 -0
  16. package/packages/animations/src/hooks/useScale.js +59 -0
  17. package/packages/animations/src/hooks/useSlide.js +53 -0
  18. package/packages/animations/src/index.js +21 -0
  19. package/packages/animations/src/reanimated.js +83 -0
  20. package/packages/core/package.json +22 -0
  21. package/packages/core/src/components/Button.jsx +92 -0
  22. package/packages/core/src/components/Card.jsx +47 -0
  23. package/packages/core/src/components/Container.jsx +61 -0
  24. package/packages/core/src/components/Input.jsx +83 -0
  25. package/packages/core/src/components/List.jsx +80 -0
  26. package/packages/core/src/components/index.js +9 -0
  27. package/packages/core/src/hooks/index.js +5 -0
  28. package/packages/core/src/hooks/useAsync.js +60 -0
  29. package/packages/core/src/hooks/useCounter.js +36 -0
  30. package/packages/core/src/hooks/useToggle.js +18 -0
  31. package/packages/core/src/index.js +5 -0
  32. package/packages/core/src/theme/index.js +67 -0
  33. package/packages/core/src/utils/helpers.js +93 -0
  34. package/packages/core/src/utils/index.js +10 -0
  35. package/packages/device/package.json +24 -0
  36. package/packages/device/src/hooks/useCamera.js +45 -0
  37. package/packages/device/src/hooks/useGallery.js +70 -0
  38. package/packages/device/src/hooks/useLocation.js +99 -0
  39. package/packages/device/src/index.js +5 -0
  40. package/packages/examples/package.json +36 -0
  41. package/packages/examples/src/experiments/animations-device/AnimationsDeviceScreen.jsx +291 -0
  42. package/packages/examples/src/experiments/basic-app/BasicAppScreen.jsx +162 -0
  43. package/packages/examples/src/experiments/components-props-state/ComponentsStateScreen.jsx +280 -0
  44. package/packages/examples/src/experiments/navigation/NavigationScreen.jsx +202 -0
  45. package/packages/examples/src/experiments/network-storage/NetworkStorageScreen.jsx +367 -0
  46. package/packages/examples/src/experiments/state-management/StateManagementScreen.jsx +255 -0
  47. package/packages/examples/src/index.js +76 -0
  48. package/packages/navigation/package.json +20 -0
  49. package/packages/navigation/src/DrawerNavigator.jsx +35 -0
  50. package/packages/navigation/src/StackNavigator.jsx +51 -0
  51. package/packages/navigation/src/TabNavigator.jsx +44 -0
  52. package/packages/navigation/src/createAppNavigator.jsx +48 -0
  53. package/packages/navigation/src/index.js +8 -0
  54. package/packages/navigation/src/types.js +18 -0
  55. package/packages/network/package.json +19 -0
  56. package/packages/network/src/apiClient.js +90 -0
  57. package/packages/network/src/fetchHelpers.js +97 -0
  58. package/packages/network/src/hooks/useFetch.js +56 -0
  59. package/packages/network/src/index.js +3 -0
  60. package/packages/network/src/types.js +4 -0
  61. package/packages/state/package.json +22 -0
  62. package/packages/state/src/context/AuthContext.jsx +94 -0
  63. package/packages/state/src/context/ThemeContext.jsx +79 -0
  64. package/packages/state/src/context/index.js +3 -0
  65. package/packages/state/src/index.js +5 -0
  66. package/packages/state/src/redux/hooks.js +12 -0
  67. package/packages/state/src/redux/index.js +7 -0
  68. package/packages/state/src/redux/slices/counterSlice.js +39 -0
  69. package/packages/state/src/redux/slices/postsSlice.js +92 -0
  70. package/packages/state/src/redux/store.js +32 -0
  71. package/packages/storage/package.json +24 -0
  72. package/packages/storage/src/asyncStorage.js +82 -0
  73. package/packages/storage/src/index.js +2 -0
  74. package/packages/storage/src/sqlite/database.js +65 -0
  75. package/packages/storage/src/sqlite/index.js +3 -0
  76. package/packages/storage/src/sqlite/operations.js +112 -0
  77. 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,9 @@
1
+ export { Container } from './Container';
2
+
3
+ export { Card } from './Card';
4
+
5
+ export { Input } from './Input';
6
+
7
+ export { Button } from './Button';
8
+
9
+ export { List } from './List';
@@ -0,0 +1,5 @@
1
+ export { useToggle } from './useToggle';
2
+
3
+ export { useCounter } from './useCounter';
4
+
5
+ export { useAsync } from './useAsync';
@@ -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,5 @@
1
+ // @expotesting/core — central barrel export
2
+ export * from './hooks/index';
3
+ export * from './components/index';
4
+ export * from './utils/index';
5
+ export * from './theme/index';
@@ -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,10 @@
1
+ export {
2
+ isError,
3
+ safeJsonParse,
4
+ throttle,
5
+ debounce,
6
+ formatDate,
7
+ clamp,
8
+ generateId,
9
+ capitalise,
10
+ groupBy } from './helpers';
@@ -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
+ }