@biruframework/native 1.0.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.
@@ -0,0 +1,29 @@
1
+ import { Pressable, Text } from 'react-native';
2
+
3
+ export function Button(props) {
4
+ const {
5
+ disabled,
6
+ loading,
7
+ onPress,
8
+ label,
9
+ style: styleProp,
10
+ textStyle: textStyleProp,
11
+ ...rest
12
+ } = props;
13
+
14
+ const isDisabled = disabled || loading;
15
+
16
+ return (
17
+ <Pressable
18
+ onPress={onPress}
19
+ disabled={isDisabled}
20
+ style={styleProp}
21
+ {...rest}
22
+ >
23
+ <Text style={textStyleProp}>
24
+ {loading ? 'Loading…' : label}
25
+ </Text>
26
+ </Pressable>
27
+ );
28
+ }
29
+
@@ -0,0 +1,62 @@
1
+ import { Pressable, Text, View } from 'react-native';
2
+
3
+ export function ButtonIcon(props) {
4
+ const {
5
+ disabled,
6
+ loading,
7
+ onPress,
8
+ label,
9
+ icon,
10
+ iconPosition = 'left',
11
+ style: styleProp,
12
+ textStyle: textStyleProp,
13
+ iconStyle: iconStyleProp,
14
+ ...rest
15
+ } = props;
16
+
17
+ const isDisabled = disabled || loading;
18
+
19
+ const renderIcon = () => {
20
+ if (!icon) return null;
21
+
22
+ // Handle different icon types: React component, Image source, or string (emoji)
23
+ if (typeof icon === 'function' || typeof icon === 'object') {
24
+ const IconComponent = icon;
25
+ return (
26
+ <View style={iconStyleProp}>
27
+ {typeof IconComponent === 'function' ? (
28
+ <IconComponent />
29
+ ) : (
30
+ icon
31
+ )}
32
+ </View>
33
+ );
34
+ }
35
+
36
+ // String/emoji icon
37
+ return (
38
+ <Text style={iconStyleProp}>
39
+ {icon}
40
+ </Text>
41
+ );
42
+ };
43
+
44
+ return (
45
+ <Pressable
46
+ onPress={onPress}
47
+ disabled={isDisabled}
48
+ style={styleProp}
49
+ {...rest}
50
+ >
51
+ <View>
52
+ {iconPosition === 'left' && renderIcon()}
53
+ {label && (
54
+ <Text style={textStyleProp}>
55
+ {loading ? 'Loading…' : label}
56
+ </Text>
57
+ )}
58
+ {iconPosition === 'right' && renderIcon()}
59
+ </View>
60
+ </Pressable>
61
+ );
62
+ }
@@ -0,0 +1,31 @@
1
+ import { View, StyleSheet } from 'react-native';
2
+ import { defaultTheme } from '../../theme/Theme';
3
+
4
+ export function Card(props) {
5
+ const theme = props.theme ?? defaultTheme;
6
+ return (
7
+ <View
8
+ style={[
9
+ styles.card,
10
+ {
11
+ backgroundColor: theme.colors.surface,
12
+ borderColor: theme.colors.border,
13
+ borderRadius: theme.radius.lg,
14
+ },
15
+ props.style,
16
+ ]}
17
+ >
18
+ {props.children}
19
+ </View>
20
+ );
21
+ }
22
+
23
+ const styles = StyleSheet.create({
24
+ card: {
25
+ borderWidth: 1,
26
+ padding: 16,
27
+ gap: 12,
28
+ },
29
+ });
30
+
31
+
@@ -0,0 +1,145 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { View, Text, Pressable, FlatList, Modal, StyleSheet } from 'react-native';
3
+ import { defaultTheme } from '../../theme/Theme';
4
+
5
+ export function Dropdown(props) {
6
+ const theme = props.theme ?? defaultTheme;
7
+ const [isOpen, setIsOpen] = useState(false);
8
+ const selectedLabel = useMemo(() => {
9
+ if (props.value == null) return null;
10
+ return props.items.find((i) => i.value === props.value)?.label ?? String(props.value);
11
+ }, [props.items, props.value]);
12
+
13
+ return (
14
+ <View style={styles.fieldWrapper}>
15
+ {props.label ? (
16
+ <Text style={[styles.label, { color: theme.colors.textMuted }]}>{props.label}</Text>
17
+ ) : null}
18
+
19
+ <Pressable
20
+ onPress={() => setIsOpen(true)}
21
+ style={[
22
+ styles.dropdownTrigger,
23
+ {
24
+ borderColor: theme.colors.border,
25
+ backgroundColor: theme.colors.surface,
26
+ borderRadius: theme.radius.md,
27
+ },
28
+ ]}
29
+ >
30
+ <Text style={{ color: theme.colors.text }}>
31
+ {selectedLabel ?? props.placeholder ?? 'Select…'}
32
+ </Text>
33
+ <Text style={{ color: theme.colors.textMuted }}>▼</Text>
34
+ </Pressable>
35
+
36
+ <Modal
37
+ visible={isOpen}
38
+ animationType="slide"
39
+ transparent
40
+ onRequestClose={() => setIsOpen(false)}
41
+ >
42
+ <View style={styles.modalOverlay}>
43
+ <View
44
+ style={[
45
+ styles.modalSheet,
46
+ {
47
+ backgroundColor: theme.colors.background,
48
+ borderColor: theme.colors.border,
49
+ },
50
+ ]}
51
+ >
52
+ <View style={styles.modalHeader}>
53
+ <Text style={[styles.modalTitle, { color: theme.colors.text }]}>
54
+ {props.label ?? 'Select'}
55
+ </Text>
56
+ <Pressable onPress={() => setIsOpen(false)}>
57
+ <Text style={{ color: theme.colors.primary }}>Close</Text>
58
+ </Pressable>
59
+ </View>
60
+
61
+ <FlatList
62
+ data={props.items}
63
+ keyExtractor={(item) => String(item.value)}
64
+ ItemSeparatorComponent={() => (
65
+ <View style={[styles.separator, { backgroundColor: theme.colors.border }]} />
66
+ )}
67
+ renderItem={({ item }) => {
68
+ const isSelected = props.value === item.value;
69
+ return (
70
+ <Pressable
71
+ onPress={() => {
72
+ props.onValueChange(item.value);
73
+ setIsOpen(false);
74
+ }}
75
+ style={[
76
+ styles.dropdownItem,
77
+ {
78
+ backgroundColor: isSelected ? theme.colors.surface : 'transparent',
79
+ },
80
+ ]}
81
+ >
82
+ <Text style={{ color: theme.colors.text }}>{item.label}</Text>
83
+ {isSelected ? <Text style={{ color: theme.colors.primary }}>✓</Text> : null}
84
+ </Pressable>
85
+ );
86
+ }}
87
+ />
88
+ </View>
89
+ </View>
90
+ </Modal>
91
+ </View>
92
+ );
93
+ }
94
+
95
+
96
+ const styles = StyleSheet.create({
97
+ fieldWrapper: {
98
+ gap: 8,
99
+ },
100
+ label: {
101
+ fontSize: 13,
102
+ },
103
+ dropdownTrigger: {
104
+ borderWidth: 1,
105
+ paddingHorizontal: 12,
106
+ paddingVertical: 12,
107
+ flexDirection: 'row',
108
+ justifyContent: 'space-between',
109
+ alignItems: 'center',
110
+ },
111
+ modalOverlay: {
112
+ flex: 1,
113
+ justifyContent: 'flex-end',
114
+ backgroundColor: 'rgba(0,0,0,0.5)',
115
+ },
116
+ modalSheet: {
117
+ borderTopWidth: 1,
118
+ paddingHorizontal: 16,
119
+ paddingTop: 12,
120
+ paddingBottom: 24,
121
+ maxHeight: '70%',
122
+ },
123
+ modalHeader: {
124
+ flexDirection: 'row',
125
+ justifyContent: 'space-between',
126
+ alignItems: 'center',
127
+ paddingBottom: 12,
128
+ },
129
+ modalTitle: {
130
+ fontSize: 16,
131
+ fontWeight: '700',
132
+ },
133
+ separator: {
134
+ height: 1,
135
+ },
136
+ dropdownItem: {
137
+ paddingVertical: 14,
138
+ paddingHorizontal: 8,
139
+ flexDirection: 'row',
140
+ justifyContent: 'space-between',
141
+ alignItems: 'center',
142
+ },
143
+ });
144
+
145
+
@@ -0,0 +1,9 @@
1
+ export * from './button/Button';
2
+ export * from './button/ButtonIcon';
3
+ export * from './card/Card';
4
+ export * from './dropdown/DropDown';
5
+ export * from './textfield/TextField';
6
+ export * from './list/List';
7
+ export * from './navigation/NavigationBar';
8
+
9
+
@@ -0,0 +1,123 @@
1
+ import { View, Text, Image, FlatList, Pressable, StyleSheet } from 'react-native';
2
+
3
+ export function List(props) {
4
+ const {
5
+ items,
6
+ onItemPress,
7
+ /**
8
+ * Optional mappers so List can work with many different data shapes.
9
+ * If not provided, it will use the default fields: image, title, description, location, id.
10
+ */
11
+ getImage, // (item) => string | require(...) | ImageSource
12
+ getTitle, // (item) => string
13
+ getDescription, // (item) => string | undefined
14
+ getLocation, // (item) => string | undefined
15
+ keyExtractor, // (item, index) => string
16
+ /**
17
+ * Style customization from the consuming app:
18
+ * - cardStyle : style for the card / item container
19
+ * - thumbnailStyle : style for the image
20
+ * - titleStyle : style for the title
21
+ * - descriptionStyle : style for the description
22
+ * - locationStyle : style for the location
23
+ * - contentContainerStyle : style for the list content container
24
+ */
25
+ cardStyle,
26
+ thumbnailStyle,
27
+ titleStyle,
28
+ descriptionStyle,
29
+ locationStyle,
30
+ contentContainerStyle,
31
+ ...flatListProps
32
+ } = props;
33
+
34
+ const renderItem = ({ item }) => {
35
+ const rawImage = getImage ? getImage(item) : item.image;
36
+ const title = getTitle ? getTitle(item) : item.title;
37
+ const description = getDescription ? getDescription(item) : item.description;
38
+ const location = getLocation ? getLocation(item) : item.location;
39
+
40
+ const imageSource =
41
+ typeof rawImage === 'string' ? { uri: rawImage } : rawImage;
42
+
43
+ return (
44
+ <Pressable
45
+ onPress={() => onItemPress && onItemPress(item)}
46
+ style={({ pressed }) => [
47
+ styles.card,
48
+ cardStyle,
49
+ pressed && { opacity: 0.9 },
50
+ ]}
51
+ >
52
+ <Image
53
+ source={imageSource}
54
+ style={[styles.thumbnail, thumbnailStyle]}
55
+ />
56
+
57
+ <View style={styles.content}>
58
+ <Text style={[styles.title, titleStyle]}>
59
+ {title}
60
+ </Text>
61
+
62
+ {description ? (
63
+ <Text
64
+ style={[styles.description, descriptionStyle]}
65
+ numberOfLines={2}
66
+ >
67
+ {description}
68
+ </Text>
69
+ ) : null}
70
+
71
+ {location ? (
72
+ <Text style={[styles.location, locationStyle]}>
73
+ {location}
74
+ </Text>
75
+ ) : null}
76
+ </View>
77
+ </Pressable>
78
+ );
79
+ };
80
+
81
+ return (
82
+ <FlatList
83
+ data={items}
84
+ keyExtractor={
85
+ keyExtractor ??
86
+ ((item, index) =>
87
+ item?.id != null ? String(item.id) : String(index))
88
+ }
89
+ contentContainerStyle={[
90
+ styles.listContent,
91
+ contentContainerStyle,
92
+ ]}
93
+ renderItem={renderItem}
94
+ showsVerticalScrollIndicator={false}
95
+ {...flatListProps}
96
+ />
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ listContent: {
102
+ paddingVertical: 4,
103
+ },
104
+ card: {
105
+ flexDirection: 'row',
106
+ alignItems: 'center',
107
+ },
108
+ thumbnail: {
109
+ width: 72,
110
+ height: 72,
111
+ borderRadius: 12,
112
+ },
113
+ content: {
114
+ flex: 1,
115
+ },
116
+ title: {
117
+ },
118
+ description: {
119
+ },
120
+ location: {
121
+ },
122
+ });
123
+
@@ -0,0 +1,77 @@
1
+ import { View, Text, Pressable } from 'react-native';
2
+
3
+ export function NavigationBar(props) {
4
+ const {
5
+ items = [], // Array of { icon, label, onPress, active }
6
+ activeIndex = 0,
7
+ style: styleProp,
8
+ itemStyle: itemStyleProp,
9
+ activeItemStyle: activeItemStyleProp,
10
+ iconStyle: iconStyleProp,
11
+ activeIconStyle: activeIconStyleProp,
12
+ labelStyle: labelStyleProp,
13
+ activeLabelStyle: activeLabelStyleProp,
14
+ ...rest
15
+ } = props;
16
+
17
+ const itemCount = items.length;
18
+
19
+ // Validasi jumlah item (4, 5, atau 6)
20
+ if (itemCount < 4 || itemCount > 6) {
21
+ console.warn(`NavigationBar: Expected 4, 5, or 6 items, but got ${itemCount}`);
22
+ }
23
+
24
+ const renderIcon = (icon, isActive) => {
25
+ if (!icon) return null;
26
+
27
+ // Handle different icon types: React component, Image source, or string (emoji)
28
+ if (typeof icon === 'function' || typeof icon === 'object') {
29
+ const IconComponent = icon;
30
+ return (
31
+ <View style={isActive ? activeIconStyleProp : iconStyleProp}>
32
+ {typeof IconComponent === 'function' ? (
33
+ <IconComponent />
34
+ ) : (
35
+ icon
36
+ )}
37
+ </View>
38
+ );
39
+ }
40
+
41
+ // String/emoji icon
42
+ return (
43
+ <Text style={isActive ? activeIconStyleProp : iconStyleProp}>
44
+ {icon}
45
+ </Text>
46
+ );
47
+ };
48
+
49
+ return (
50
+ <View style={styleProp} {...rest}>
51
+ {items.map((item, index) => {
52
+ const isActive = item.active !== undefined ? item.active : activeIndex === index;
53
+
54
+ return (
55
+ <Pressable
56
+ key={index}
57
+ onPress={item.onPress}
58
+ style={[
59
+ itemStyleProp,
60
+ isActive && activeItemStyleProp,
61
+ ]}
62
+ >
63
+ {renderIcon(item.icon, isActive)}
64
+ {item.label && (
65
+ <Text style={[
66
+ labelStyleProp,
67
+ isActive && activeLabelStyleProp,
68
+ ]}>
69
+ {item.label}
70
+ </Text>
71
+ )}
72
+ </Pressable>
73
+ );
74
+ })}
75
+ </View>
76
+ );
77
+ }
@@ -0,0 +1,30 @@
1
+ import { View, Text, TextInput } from 'react-native';
2
+ import { defaultTheme } from '../../theme/Theme';
3
+
4
+ export function TextField(props) {
5
+ const theme = props.theme ?? defaultTheme;
6
+ const {
7
+ label,
8
+ errorText,
9
+ style,
10
+ labelStyle,
11
+ containerStyle,
12
+ ...inputProps
13
+ } = props;
14
+
15
+ return (
16
+ <View style={containerStyle}>
17
+ {label ? (
18
+ <Text style={labelStyle}>{label}</Text>
19
+ ) : null}
20
+ <TextInput
21
+ placeholderTextColor={theme.colors.textMuted}
22
+ {...inputProps}
23
+ style={style}
24
+ />
25
+ {errorText ? (
26
+ <Text>{errorText}</Text>
27
+ ) : null}
28
+ </View>
29
+ );
30
+ }
@@ -0,0 +1,21 @@
1
+ import { Controller } from 'react-hook-form';
2
+
3
+ import { TextField } from '../components';
4
+
5
+ export function FormTextField(props) {
6
+ const { control, name, ...textFieldProps } = props;
7
+ return (
8
+ <Controller
9
+ control={control}
10
+ name={name}
11
+ render={({ field: { onChange, value }, fieldState }) => (
12
+ <TextField
13
+ {...textFieldProps}
14
+ value={value ?? ''}
15
+ onChangeText={onChange}
16
+ errorText={fieldState.error?.message}
17
+ />
18
+ )}
19
+ />
20
+ );
21
+ }
package/hooks/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default as useFocus } from './useFocus.js';
@@ -0,0 +1,19 @@
1
+ import { useFocusEffect } from '@react-navigation/native';
2
+ import { useCallback } from 'react';
3
+
4
+ export default function useFocus({ callback, setState }) {
5
+ useFocusEffect(
6
+ useCallback(() => {
7
+ const fetchLists = async () => {
8
+ try {
9
+ const data = await callback.getLists();
10
+ setState(data);
11
+ } catch (error) {
12
+ console.error(error);
13
+ }
14
+ };
15
+
16
+ fetchLists();
17
+ }, [callback, setState])
18
+ );
19
+ }
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @biruframework/native - React Native / Android: UI, hooks, navigation, forms, state-redux
3
+ * Install: pnpm add @biruframework/core @biruframework/native
4
+ */
5
+ export * from './components';
6
+ export * from './theme';
7
+ export * from './ui';
8
+ export * from './hooks';
9
+ export * from './navigation';
10
+ export * from './forms';
11
+ export * from './state';
@@ -0,0 +1,20 @@
1
+ export { NavigationContainer } from '@react-navigation/native';
2
+ export { createNativeStackNavigator } from '@react-navigation/native-stack';
3
+
4
+ /**
5
+ * Create React Navigation linking config from @biruframework/core route definitions.
6
+ * Minimal helper (1-level). You can still override/extend manually.
7
+ */
8
+ export function linkingFromRoutes(
9
+ routes,
10
+ options,
11
+ ) {
12
+ const screens = {};
13
+ for (const [screenName, route] of Object.entries(routes)) {
14
+ if (route.path) screens[screenName] = route.path.replace(/^\//, '');
15
+ }
16
+ return {
17
+ prefixes: options.prefixes,
18
+ config: { screens },
19
+ };
20
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@biruframework/native",
3
+ "version": "1.0.0",
4
+ "description": "React Native / Android: UI, hooks, navigation, forms, state-redux",
5
+ "main": "index.js",
6
+ "exports": { ".": "./index.js" },
7
+ "peerDependencies": {
8
+ "@biruframework/core": "*",
9
+ "react": "*",
10
+ "react-native": "*",
11
+ "@react-navigation/native": "*",
12
+ "react-hook-form": "*"
13
+ },
14
+ "keywords": ["biruframework", "react-native", "android", "ui"],
15
+ "license": "ISC"
16
+ }
package/state/index.js ADDED
@@ -0,0 +1,27 @@
1
+ export {
2
+ configureStore,
3
+ createAction,
4
+ createAsyncThunk,
5
+ createListenerMiddleware,
6
+ createReducer,
7
+ createSelector,
8
+ createSlice,
9
+ isAnyOf,
10
+ isAsyncThunkAction,
11
+ isFulfilled,
12
+ isPending,
13
+ isRejected,
14
+ isRejectedWithValue,
15
+ } from '@reduxjs/toolkit';
16
+
17
+ import { Provider, useDispatch, useSelector } from 'react-redux';
18
+
19
+ export { Provider, useDispatch, useSelector };
20
+
21
+ /**
22
+ * Hooks helper. In your app, call:
23
+ * const { useAppDispatch, useAppSelector } = createReduxHooks();
24
+ */
25
+ export function createReduxHooks() {
26
+ return { useAppDispatch: useDispatch, useAppSelector: useSelector };
27
+ }
package/theme/Theme.js ADDED
@@ -0,0 +1,15 @@
1
+ export const defaultTheme = {
2
+ colors: {
3
+ background: '#0b0b0f',
4
+ surface: '#141420',
5
+ text: '#ffffff',
6
+ textMuted: '#a1a1aa',
7
+ primary: '#7c3aed',
8
+ border: '#2a2a3a',
9
+ danger: '#ef4444',
10
+ },
11
+ radius: {
12
+ md: 12,
13
+ lg: 16,
14
+ },
15
+ };
@@ -0,0 +1,14 @@
1
+ import React, { createContext, useContext, useMemo } from 'react';
2
+
3
+ import { defaultTheme } from './Theme';
4
+
5
+ const ThemeContext = createContext(defaultTheme);
6
+
7
+ export function ThemeProvider(props) {
8
+ const theme = useMemo(() => props.theme ?? defaultTheme, [props.theme]);
9
+ return <ThemeContext.Provider value={theme}>{props.children}</ThemeContext.Provider>;
10
+ }
11
+
12
+ export function useTheme() {
13
+ return useContext(ThemeContext);
14
+ }
package/theme/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { defaultTheme } from './Theme';
2
+ export { ThemeProvider, useTheme } from './context.jsx';
@@ -0,0 +1,73 @@
1
+ import React, { useMemo } from 'react';
2
+ import { Pressable, StyleSheet, Switch, Text, View } from 'react-native';
3
+
4
+ import { defaultTheme } from '../theme/Theme';
5
+
6
+ export function Checkbox(props) {
7
+ const theme = props.theme ?? defaultTheme;
8
+ const isDisabled = props.disabled ?? false;
9
+
10
+ const boxStyle = useMemo(
11
+ () => [
12
+ styles.checkboxBox,
13
+ {
14
+ borderColor: theme.colors.border,
15
+ backgroundColor: props.checked ? theme.colors.primary : theme.colors.surface,
16
+ opacity: isDisabled ? 0.6 : 1,
17
+ },
18
+ ],
19
+ [isDisabled, props.checked, theme.colors.border, theme.colors.primary, theme.colors.surface],
20
+ );
21
+
22
+ return (
23
+ <Pressable
24
+ disabled={isDisabled}
25
+ onPress={() => props.onCheckedChange(!props.checked)}
26
+ style={styles.checkboxRow}
27
+ >
28
+ <View style={boxStyle}>
29
+ {props.checked ? <Text style={{ color: theme.colors.text, fontWeight: '900' }}>✓</Text> : null}
30
+ </View>
31
+ {props.label ? <Text style={{ color: theme.colors.text }}>{props.label}</Text> : null}
32
+ </Pressable>
33
+ );
34
+ }
35
+
36
+ export function SwitchRow(props) {
37
+ const theme = props.theme ?? defaultTheme;
38
+ const isDisabled = props.disabled ?? false;
39
+
40
+ return (
41
+ <View style={styles.switchRow}>
42
+ <Text style={{ color: theme.colors.text }}>{props.label}</Text>
43
+ <Switch
44
+ disabled={isDisabled}
45
+ value={props.value}
46
+ onValueChange={props.onValueChange}
47
+ trackColor={{ true: theme.colors.primary, false: theme.colors.border }}
48
+ thumbColor={theme.colors.text}
49
+ />
50
+ </View>
51
+ );
52
+ }
53
+
54
+ const styles = StyleSheet.create({
55
+ checkboxRow: {
56
+ flexDirection: 'row',
57
+ alignItems: 'center',
58
+ gap: 10,
59
+ },
60
+ checkboxBox: {
61
+ width: 22,
62
+ height: 22,
63
+ borderWidth: 1,
64
+ borderRadius: 6,
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ },
68
+ switchRow: {
69
+ flexDirection: 'row',
70
+ justifyContent: 'space-between',
71
+ alignItems: 'center',
72
+ },
73
+ });
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import { defaultTheme } from '../theme/Theme';
5
+
6
+ export function Divider(props) {
7
+ const theme = props.theme ?? defaultTheme;
8
+ return <View style={[styles.divider, { backgroundColor: theme.colors.border }, props.style]} />;
9
+ }
10
+
11
+ export function Badge(props) {
12
+ const theme = props.theme ?? defaultTheme;
13
+ return (
14
+ <View
15
+ style={[
16
+ styles.badge,
17
+ { backgroundColor: props.backgroundColor ?? theme.colors.surface, borderColor: theme.colors.border },
18
+ ]}
19
+ >
20
+ <Text style={{ color: theme.colors.textMuted, fontSize: 12, fontWeight: '600' }}>
21
+ {props.label}
22
+ </Text>
23
+ </View>
24
+ );
25
+ }
26
+
27
+ export function Loading(props) {
28
+ const theme = props.theme ?? defaultTheme;
29
+ return (
30
+ <View style={styles.loading}>
31
+ <ActivityIndicator color={theme.colors.primary} />
32
+ {props.label ? <Text style={{ color: theme.colors.textMuted }}>{props.label}</Text> : null}
33
+ </View>
34
+ );
35
+ }
36
+
37
+ const styles = StyleSheet.create({
38
+ divider: {
39
+ height: 1,
40
+ width: '100%',
41
+ },
42
+ badge: {
43
+ paddingHorizontal: 10,
44
+ paddingVertical: 6,
45
+ borderWidth: 1,
46
+ borderRadius: 999,
47
+ alignSelf: 'flex-start',
48
+ },
49
+ loading: {
50
+ flexDirection: 'row',
51
+ alignItems: 'center',
52
+ gap: 10,
53
+ },
54
+ });
package/ui/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from './layout';
2
+ export * from './typography';
3
+ export * from './feedback';
4
+ export * from './controls';
5
+ export * from './modalSheet';
6
+ export * from './toast';
7
+ export * from './screen';
package/ui/layout.jsx ADDED
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ export function Stack(props) {
5
+ const childrenArray = React.Children.toArray(props.children);
6
+ return (
7
+ <View style={[{ flexDirection: 'column' }, props.style]}>
8
+ {childrenArray.map((child, index) => (
9
+ <View key={index} style={{ marginTop: index === 0 ? 0 : props.gap ?? 0 }}>
10
+ {child}
11
+ </View>
12
+ ))}
13
+ </View>
14
+ );
15
+ }
16
+
17
+ export function Row(props) {
18
+ const childrenArray = React.Children.toArray(props.children);
19
+ return (
20
+ <View
21
+ style={[
22
+ {
23
+ flexDirection: 'row',
24
+ alignItems: props.align ?? 'center',
25
+ justifyContent: props.justify ?? 'flex-start',
26
+ flexWrap: props.wrap ? 'wrap' : 'nowrap',
27
+ },
28
+ props.style,
29
+ ]}
30
+ >
31
+ {childrenArray.map((child, index) => (
32
+ <View key={index} style={{ marginLeft: index === 0 ? 0 : props.gap ?? 0 }}>
33
+ {child}
34
+ </View>
35
+ ))}
36
+ </View>
37
+ );
38
+ }
39
+
40
+ export function Spacer(props) {
41
+ return <View style={props.horizontal ? { width: props.size } : { height: props.size }} />;
42
+ }
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import { defaultTheme } from '../theme/Theme';
5
+
6
+ export function ModalSheet(props) {
7
+ const theme = props.theme ?? defaultTheme;
8
+
9
+ return (
10
+ <Modal visible={props.visible} animationType="slide" transparent onRequestClose={props.onClose}>
11
+ <Pressable style={styles.overlay} onPress={props.onClose}>
12
+ <View />
13
+ </Pressable>
14
+ <View
15
+ style={[
16
+ styles.sheet,
17
+ { backgroundColor: theme.colors.background, borderColor: theme.colors.border },
18
+ ]}
19
+ >
20
+ <View style={styles.header}>
21
+ <Text style={[styles.title, { color: theme.colors.text }]}>
22
+ {props.title ?? 'Sheet'}
23
+ </Text>
24
+ <Pressable onPress={props.onClose}>
25
+ <Text style={{ color: theme.colors.primary, fontWeight: '700' }}>Close</Text>
26
+ </Pressable>
27
+ </View>
28
+ <View style={styles.content}>{props.children}</View>
29
+ </View>
30
+ </Modal>
31
+ );
32
+ }
33
+
34
+ const styles = StyleSheet.create({
35
+ overlay: {
36
+ flex: 1,
37
+ backgroundColor: 'rgba(0,0,0,0.5)',
38
+ },
39
+ sheet: {
40
+ borderTopWidth: 1,
41
+ paddingHorizontal: 16,
42
+ paddingTop: 12,
43
+ paddingBottom: 24,
44
+ },
45
+ header: {
46
+ flexDirection: 'row',
47
+ justifyContent: 'space-between',
48
+ alignItems: 'center',
49
+ },
50
+ title: {
51
+ fontSize: 16,
52
+ fontWeight: '800',
53
+ },
54
+ content: {
55
+ marginTop: 12,
56
+ gap: 12,
57
+ },
58
+ });
package/ui/screen.jsx ADDED
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import { SafeAreaView, ScrollView, StyleSheet } from 'react-native';
3
+
4
+ import { defaultTheme } from '../theme/Theme';
5
+
6
+ export function Screen(props) {
7
+ const theme = props.theme ?? defaultTheme;
8
+
9
+ if (props.scroll) {
10
+ return (
11
+ <SafeAreaView style={[styles.safe, { backgroundColor: theme.colors.background }, props.style]}>
12
+ <ScrollView contentContainerStyle={[styles.content, props.contentStyle]}>{props.children}</ScrollView>
13
+ </SafeAreaView>
14
+ );
15
+ }
16
+
17
+ return (
18
+ <SafeAreaView style={[styles.safe, { backgroundColor: theme.colors.background }, props.style]}>
19
+ {props.children}
20
+ </SafeAreaView>
21
+ );
22
+ }
23
+
24
+ const styles = StyleSheet.create({
25
+ safe: { flex: 1 },
26
+ content: { padding: 16, gap: 12 },
27
+ });
package/ui/toast.jsx ADDED
@@ -0,0 +1,87 @@
1
+ import React, { createContext, useContext, useMemo, useRef, useState } from 'react';
2
+ import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import { defaultTheme } from '../theme/Theme';
5
+
6
+ const ToastContext = createContext(null);
7
+
8
+ export function ToastProvider(props) {
9
+ const theme = props.theme ?? defaultTheme;
10
+ const [toast, setToast] = useState(null);
11
+ const timer = useRef(null);
12
+ const opacity = useRef(new Animated.Value(0)).current;
13
+
14
+ const api = useMemo(
15
+ () => ({
16
+ show: (options) => {
17
+ if (timer.current) clearTimeout(timer.current);
18
+ setToast(options);
19
+ Animated.timing(opacity, { toValue: 1, duration: 160, useNativeDriver: true }).start();
20
+ timer.current = setTimeout(() => {
21
+ Animated.timing(opacity, { toValue: 0, duration: 160, useNativeDriver: true }).start(() => {
22
+ setToast(null);
23
+ });
24
+ }, options.durationMs ?? 2500);
25
+ },
26
+ hide: () => {
27
+ if (timer.current) clearTimeout(timer.current);
28
+ Animated.timing(opacity, { toValue: 0, duration: 160, useNativeDriver: true }).start(() => {
29
+ setToast(null);
30
+ });
31
+ },
32
+ }),
33
+ [opacity],
34
+ );
35
+
36
+ const backgroundColor =
37
+ toast?.variant === 'danger'
38
+ ? theme.colors.danger
39
+ : toast?.variant === 'success'
40
+ ? '#16a34a'
41
+ : theme.colors.surface;
42
+
43
+ return (
44
+ <ToastContext.Provider value={api}>
45
+ {props.children}
46
+ {toast ? (
47
+ <Animated.View style={[styles.toastWrap, { opacity }]}>
48
+ <Pressable
49
+ onPress={() => api.hide()}
50
+ style={[
51
+ styles.toast,
52
+ { backgroundColor, borderColor: theme.colors.border },
53
+ ]}
54
+ >
55
+ <Text style={{ color: theme.colors.text }}>{toast.message}</Text>
56
+ <View style={{ width: 10 }} />
57
+ <Text style={{ color: theme.colors.textMuted }}>Tap to dismiss</Text>
58
+ </Pressable>
59
+ </Animated.View>
60
+ ) : null}
61
+ </ToastContext.Provider>
62
+ );
63
+ }
64
+
65
+ export function useToast() {
66
+ const ctx = useContext(ToastContext);
67
+ if (!ctx) throw new Error('useToast must be used within ToastProvider');
68
+ return ctx;
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ toastWrap: {
73
+ position: 'absolute',
74
+ left: 16,
75
+ right: 16,
76
+ bottom: 24,
77
+ },
78
+ toast: {
79
+ borderWidth: 1,
80
+ borderRadius: 14,
81
+ paddingHorizontal: 14,
82
+ paddingVertical: 12,
83
+ flexDirection: 'row',
84
+ alignItems: 'center',
85
+ justifyContent: 'space-between',
86
+ },
87
+ });
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { Text } from 'react-native';
3
+
4
+ import { defaultTheme } from '../theme/Theme';
5
+
6
+ export function Title(props) {
7
+ const theme = props.theme ?? defaultTheme;
8
+ return (
9
+ <Text
10
+ {...props}
11
+ style={[{ fontSize: 20, fontWeight: '800', color: theme.colors.text }, props.style]}
12
+ />
13
+ );
14
+ }
15
+
16
+ export function Subtitle(props) {
17
+ const theme = props.theme ?? defaultTheme;
18
+ return (
19
+ <Text
20
+ {...props}
21
+ style={[{ fontSize: 14, color: theme.colors.textMuted }, props.style]}
22
+ />
23
+ );
24
+ }
25
+
26
+ export function SectionTitle(props) {
27
+ const theme = props.theme ?? defaultTheme;
28
+ return (
29
+ <Text
30
+ {...props}
31
+ style={[{ fontSize: 16, fontWeight: '700', color: theme.colors.text }, props.style]}
32
+ />
33
+ );
34
+ }
35
+
36
+ export function BodyText(props) {
37
+ const theme = props.theme ?? defaultTheme;
38
+ return (
39
+ <Text
40
+ {...props}
41
+ style={[{ fontSize: 14, lineHeight: 20, color: theme.colors.textMuted }, props.style]}
42
+ />
43
+ );
44
+ }