feat: add expo mobile application source code

This commit is contained in:
2026-05-21 16:06:35 +07:00
parent 76d7a5c5c6
commit 0c65a7811b
77 changed files with 20356 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useRef } from 'react';
import { StyleSheet, Dimensions, Text, View, StatusBar, Animated, Easing } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppConfig } from '../context/ConfigContext';
import { Image } from 'expo-image';
const { width, height } = Dimensions.get('window');
interface AnimatedSplashProps {
onAnimationComplete: () => void;
}
export const AnimatedSplash: React.FC<AnimatedSplashProps> = ({ onAnimationComplete }) => {
const { isDark, colors } = useAppTheme();
const { config } = useAppConfig();
// Using standard React Native Animated for safety
const logoScale = useRef(new Animated.Value(0.5)).current;
const logoOpacity = useRef(new Animated.Value(0)).current;
const containerOpacity = useRef(new Animated.Value(1)).current;
const textOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
// 1. Entry Animation
Animated.parallel([
Animated.timing(logoOpacity, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(logoScale, {
toValue: 1,
duration: 1200,
easing: Easing.out(Easing.back(1.5)),
useNativeDriver: true
}),
Animated.timing(textOpacity, {
toValue: 1,
duration: 800,
delay: 1000,
useNativeDriver: true
})
]).start();
// 2. Clear Exit
const timer = setTimeout(() => {
Animated.timing(containerOpacity, {
toValue: 0,
duration: 600,
useNativeDriver: true
}).start(() => {
// Delay to ensure the animation frame finishes before state change triggers unmount
setTimeout(() => {
onAnimationComplete();
}, 100);
});
}, 3500);
return () => clearTimeout(timer);
}, []);
return (
<Animated.View style={[styles.container, { opacity: containerOpacity, backgroundColor: isDark ? '#020617' : '#F8FAFC' }]}>
<StatusBar hidden />
<View style={styles.centerBox}>
<Animated.View style={[
styles.logoWrapper,
{
opacity: logoOpacity,
transform: [{ scale: logoScale }],
}
]}>
{config?.branding?.logo_url ? (
<Image
source={{ uri: config.branding.logo_url }}
style={{ width: 100, height: 100 }}
contentFit="contain"
/>
) : (
<MaterialCommunityIcons name="shield-check" size={80} color={isDark ? '#38BDF8' : colors.primary || '#6C63FF'} />
)}
</Animated.View>
<Animated.View style={[
styles.textWrapper,
{
opacity: textOpacity,
transform: [{
translateY: textOpacity.interpolate({
inputRange: [0, 1],
outputRange: [30, 0]
})
}]
}
]}>
<View style={styles.brandContainer}>
<Text style={[styles.brandAI, { color: isDark ? '#FFF' : '#0F172A' }]}>
{config?.branding?.app_name || 'biiproject'}
</Text>
</View>
<View style={styles.taglineBox}>
<View style={styles.line} />
<Text style={styles.tagline}>{config?.branding?.app_tagline || 'DIGITAL SOLUTIONS'}</Text>
<View style={styles.line} />
</View>
</Animated.View>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
},
centerBox: {
alignItems: 'center',
},
logoWrapper: {
width: 150,
height: 150,
justifyContent: 'center',
alignItems: 'center',
},
brandContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 35,
},
brandAI: {
fontSize: 46,
fontFamily: 'Outfit_700Bold',
letterSpacing: 2,
},
textWrapper: {
alignItems: 'center',
},
taglineBox: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 15,
},
line: {
width: 25,
height: 1,
backgroundColor: '#E2E8F0',
marginHorizontal: 12,
},
tagline: {
fontSize: 11,
color: '#94A3B8',
fontFamily: 'Outfit_700Bold',
letterSpacing: 3,
}
});
+100
View File
@@ -0,0 +1,100 @@
import React, { useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Animated, TouchableOpacity, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
interface AnnouncementProps {
visible: boolean;
message: string;
type?: 'info' | 'warning' | 'danger';
onClose?: () => void;
}
export const AnnouncementBanner = ({ visible, message, type = 'info', onClose }: AnnouncementProps) => {
const { colors, isDark } = useAppTheme();
const slideAnim = useRef(new Animated.Value(-100)).current;
const hasShownRef = useRef(false);
useEffect(() => {
if (visible) {
hasShownRef.current = true;
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
tension: 40,
friction: 7
}).start();
} else {
Animated.timing(slideAnim, {
toValue: -150,
duration: 300,
useNativeDriver: true,
}).start();
}
}, [visible]);
if (!visible && !hasShownRef.current) return null;
const getTheme = () => {
switch (type) {
case 'warning': return { bg: '#FFB000', icon: 'alert-circle', text: '#000' };
case 'danger': return { bg: colors.error, icon: 'slash', text: '#FFF' };
default: return { bg: colors.primary, icon: 'info', text: isDark ? '#000' : '#000' };
}
};
const theme = getTheme();
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: theme.bg,
transform: [{ translateY: slideAnim }]
}
]}
>
<View style={styles.content}>
<Feather name={theme.icon as any} size={18} color={theme.text} />
<Text style={[styles.message, { color: theme.text }]}>{message}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Feather name="x" size={18} color={theme.text} />
</TouchableOpacity>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2000,
paddingTop: Platform.OS === 'ios' ? 50 : 30,
paddingBottom: 15,
paddingHorizontal: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 10,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
message: {
flex: 1,
fontSize: 13,
fontFamily: 'Outfit_600SemiBold',
lineHeight: 18,
},
closeBtn: {
padding: 4,
opacity: 0.7,
}
});
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import {
RefreshControl,
Platform,
StyleSheet,
View,
StatusBar
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { useAppTheme } from '../context/ThemeContext';
import { useRefresh } from '../context/RefreshContext';
interface AppScreenProps {
children: React.ReactNode;
scrollable?: boolean;
onRefresh?: () => Promise<void>;
containerStyle?: any;
}
export const AppScreen = ({ children, scrollable = true, onRefresh, containerStyle }: AppScreenProps) => {
const { colors, isDark } = useAppTheme();
const { refreshing, refreshAll } = useRefresh();
const insets = useSafeAreaInsets();
const handleRefresh = async () => {
await refreshAll();
if (onRefresh) {
await onRefresh();
}
};
const bg = isDark ? '#111111' : '#F5F5F5';
const content = (
<KeyboardAwareScrollView
style={{ flex: 1 }}
contentContainerStyle={{ flexGrow: 1 }}
showsVerticalScrollIndicator={false}
enableOnAndroid={true}
extraScrollHeight={Platform.OS === 'ios' ? 40 : 60}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
progressBackgroundColor={isDark ? '#1A1A1A' : '#FFFFFF'}
/>
}
>
<View style={{ flex: 1, paddingBottom: insets.bottom }}>
{children}
</View>
</KeyboardAwareScrollView>
);
return (
<View style={[styles.container, { backgroundColor: bg }, containerStyle]}>
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} translucent backgroundColor="transparent" />
{/* Handling top padding manually for more control than default SafeAreaView */}
<View style={[
{ flex: 1, paddingTop: insets.top },
Platform.OS === 'web' && styles.webMaxWidth
]}>
{scrollable ? content : <View style={{ flex: 1, paddingBottom: insets.bottom }}>{children}</View>}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
webMaxWidth: {
width: '100%',
backgroundColor: 'transparent',
}
});
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { TouchableOpacity, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator, StyleProp } from 'react-native';
import * as Haptics from 'expo-haptics';
import { useAppTheme } from '../context/ThemeContext';
interface ButtonProps {
onPress: () => void;
title: string;
variant?: 'primary' | 'secondary' | 'outline' | 'error';
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
disabled?: boolean;
loading?: boolean;
}
const LIME = '#C6F135';
export const Button: React.FC<ButtonProps> = ({
onPress,
title,
variant = 'primary',
style,
textStyle,
disabled,
loading
}) => {
const { colors, isDark } = useAppTheme();
const handlePress = () => {
if (disabled || loading) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
};
const getBackgroundColor = () => {
if (disabled || loading) return isDark ? '#222' : '#EEE';
switch (variant) {
case 'primary': return isDark ? LIME : '#1A1A1A';
case 'secondary': return isDark ? '#2A2A2A' : '#F5F5F5';
case 'outline': return 'transparent';
case 'error': return '#EF4444';
default: return isDark ? LIME : '#1A1A1A';
}
};
const getTextColor = () => {
if (disabled || loading) return isDark ? '#444' : '#BBB';
switch (variant) {
case 'outline': return isDark ? LIME : '#1A1A1A';
case 'primary': return isDark ? '#1A1A1A' : '#FFFFFF';
case 'secondary': return colors.text;
case 'error': return '#FFFFFF';
default: return isDark ? '#1A1A1A' : '#FFFFFF';
}
};
return (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.85}
disabled={disabled || loading}
style={[
styles.button,
{ backgroundColor: getBackgroundColor() },
variant === 'outline' && { borderWidth: 1.5, borderColor: isDark ? LIME : '#1A1A1A' },
style
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} />
) : (
<Text style={[styles.text, { color: getTextColor() }, textStyle]}>
{title}
</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
height: 58,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
text: {
fontSize: 16,
fontFamily: 'Outfit_700Bold',
},
});
+129
View File
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
import { Feather } from '@expo/vector-icons';
import { Popup } from './Popup';
interface DropdownProps {
label?: string;
value: string;
options: string[];
onSelect: (val: string) => void;
required?: boolean;
infoText?: string;
placeholder?: string;
}
const LIME = '#C6F135';
export const Dropdown: React.FC<DropdownProps> = ({
label,
value,
options,
onSelect,
required = false,
infoText,
placeholder = 'Choose an option...'
}) => {
const { colors, isDark } = useAppTheme();
const [isOpen, setIsOpen] = useState(false);
const bg = isDark ? '#1A1A1A' : '#F5F5F5';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<View style={styles.container}>
{label && (
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
{required ? <Text style={{ color: '#EF4444' }}> *</Text> : <Text style={{ color: isDark ? '#666' : '#CCC', fontSize: 10 }}> (Optional)</Text>}
</Text>
</View>
)}
<TouchableOpacity
style={[styles.inputBox, { backgroundColor: bg, borderColor: border }]}
onPress={() => setIsOpen(true)}
>
<Text style={[styles.inputText, { color: value ? colors.text : (isDark ? '#444' : '#BBB') }]}>
{value || placeholder}
</Text>
<Feather name="chevron-down" size={18} color={isDark ? '#555' : '#AAA'} />
</TouchableOpacity>
{infoText && <Text style={[styles.infoText, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>{infoText}</Text>}
<Popup visible={isOpen} onClose={() => setIsOpen(false)} title={`${label || 'Option'}`}>
<View style={styles.listContainer}>
{options.map((opt, idx) => (
<TouchableOpacity
key={idx}
style={[styles.optionItem, { borderBottomColor: border, borderBottomWidth: idx < options.length - 1 ? 1 : 0 }]}
onPress={() => {
onSelect(opt);
setIsOpen(false);
}}
>
<Text style={[styles.optionText, { color: colors.text, fontFamily: value === opt ? 'Outfit_700Bold' : 'Outfit_400Regular' }]}>
{opt}
</Text>
{value === opt && <Feather name="check-circle" size={18} color={LIME} />}
</TouchableOpacity>
))}
</View>
</Popup>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 18,
width: '100%',
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 8,
},
label: {
fontSize: 11,
fontFamily: 'Outfit_700Bold',
letterSpacing: 1,
textTransform: 'uppercase',
},
inputBox: {
height: 56,
borderRadius: 14,
paddingHorizontal: 16,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
inputText: {
fontSize: 15,
fontFamily: 'Outfit_500Medium',
flex: 1,
},
infoText: {
fontSize: 12,
marginTop: 6,
marginLeft: 4,
fontFamily: 'Outfit_400Regular',
},
listContainer: {
paddingTop: 8,
},
optionItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 18,
},
optionText: {
fontSize: 15,
},
});
+225
View File
@@ -0,0 +1,225 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView } from 'react-native';
import { Popup } from './Popup';
import { Input } from './Input';
import { Dropdown } from './Dropdown';
import { Button } from './Button';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
import * as ImagePicker from 'expo-image-picker';
import { useToast } from '../context/ToastContext';
interface DynamicFormPopupProps {
visible: boolean;
onClose: () => void;
formType: string | null;
}
const LIME = '#C6F135';
export const DynamicFormPopup: React.FC<DynamicFormPopupProps> = ({ visible, onClose, formType }) => {
const { colors, isDark } = useAppTheme();
const { showToast } = useToast();
const [images, setImages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [field1, setField1] = useState('');
const [field2, setField2] = useState('');
const [field3, setField3] = useState('');
const pickImage = async (useCamera: boolean) => {
try {
let result;
if (useCamera) {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
showToast('Camera permission denied', 'error');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
} else {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
showToast('Gallery permission denied', 'error');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
}
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
} catch (error) {
console.error(error);
showToast('Error selecting image', 'error');
}
};
const removeImage = (index: number) => {
const newImages = [...images];
newImages.splice(index, 1);
setImages(newImages);
};
const handleSubmit = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
showToast(`${formType} submitted successfully!`, 'success');
setField1('');
setField2('');
setField3('');
setImages([]);
onClose();
}, 1500);
};
const renderFields = () => {
switch (formType) {
case 'Feature A':
return (
<>
<Input label="Reference ID" value={field1} onChangeText={setField1} required placeholder="Example: REF-001" />
<Dropdown label="Category" value={field2} options={['Option 1', 'Option 2', 'Option 3']} onSelect={setField2} required />
<Input label="Description" value={field3} onChangeText={setField3} multiline required infoText="Enter detailed information here." placeholder="Lorem ipsum dolor sit amet..." />
</>
);
case 'Feature B':
return (
<>
<Dropdown label="Type" value={field1} options={['Type A', 'Type B', 'Type C']} onSelect={setField1} required />
<Input label="Location" value={field2} onChangeText={setField2} required placeholder="Example: Area 51" />
<Input label="Notes" value={field3} onChangeText={setField3} multiline required infoText="Additional notes or comments." />
</>
);
default:
return (
<>
<Input label="Title" value={field1} onChangeText={setField1} required placeholder="Enter entry title" />
<Input label="Additional Info" value={field2} onChangeText={setField2} multiline placeholder="Enter details..." />
</>
);
}
};
if (!formType) return null;
const bg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<Popup visible={visible} onClose={onClose} title={`${formType}`} type="bottom">
<View style={styles.container}>
{renderFields()}
<Text style={[styles.label, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>Attachments</Text>
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.attachBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5', borderColor: border }]}
onPress={() => pickImage(true)}
>
<Feather name="camera" size={18} color={LIME} />
<Text style={[styles.attachText, { color: colors.text }]}>Camera</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.attachBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5', borderColor: border }]}
onPress={() => pickImage(false)}
>
<Feather name="image" size={18} color={LIME} />
<Text style={[styles.attachText, { color: colors.text }]}>Gallery</Text>
</TouchableOpacity>
</View>
{images.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.imageList}>
{images.map((uri, idx) => (
<View key={idx} style={styles.imageWrapper}>
<Image source={{ uri }} style={[styles.imagePreview, { borderColor: border }]} />
<TouchableOpacity
style={[styles.deleteBadge, { backgroundColor: '#EF4444' }]}
onPress={() => removeImage(idx)}
>
<Feather name="x" size={10} color="#FFF" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<Button
title="Submit Entry"
onPress={handleSubmit}
loading={loading}
style={{ marginTop: 24 }}
/>
</View>
</Popup>
);
};
const styles = StyleSheet.create({
container: {
paddingTop: 8,
},
label: {
fontSize: 11,
fontFamily: 'Outfit_700Bold',
marginBottom: 10,
marginTop: 16,
textTransform: 'uppercase',
letterSpacing: 1,
},
actionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 16,
},
attachBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 14,
borderWidth: 1,
gap: 10,
},
attachText: {
fontSize: 14,
fontFamily: 'Outfit_600SemiBold',
},
imageList: {
flexDirection: 'row',
marginBottom: 10,
},
imageWrapper: {
marginRight: 14,
position: 'relative',
},
imagePreview: {
width: 80,
height: 80,
borderRadius: 14,
borderWidth: 1,
},
deleteBadge: {
position: 'absolute',
top: -6,
right: -6,
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#FFF',
},
});
+78
View File
@@ -0,0 +1,78 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView } from 'react-native';
import { Feather } from '@expo/vector-icons';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
// Technical fix: Report error to backend logs
const { ApiService } = require('../services/api');
ApiService.reportError(error.message, 'critical', {
componentStack: errorInfo.componentStack,
platform: require('react-native').Platform.OS,
});
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
};
public render() {
if (this.state.hasError) {
return (
<View key="error-fallback" style={[styles.container, { paddingTop: 60 }]}>
<View style={styles.content}>
<View style={styles.iconBox}>
<Feather name="alert-triangle" size={60} color="#FF4B4B" />
</View>
<Text style={styles.title}>Oops! Something went wrong</Text>
<Text style={styles.desc}>
An unexpected error occurred. Don't worry, your data is safe.
</Text>
{__DEV__ && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{this.state.error?.toString()}</Text>
</View>
)}
<TouchableOpacity style={styles.btn} onPress={this.handleReset}>
<Text style={styles.btnText}>Try Again</Text>
</TouchableOpacity>
</View>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#FFFFFF' },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 },
iconBox: { width: 120, height: 120, borderRadius: 40, backgroundColor: '#FFF0F0', alignItems: 'center', justifyContent: 'center', marginBottom: 30 },
title: { fontSize: 24, fontFamily: 'Outfit_800ExtraBold', color: '#1A1A1A', textAlign: 'center', marginBottom: 12 },
desc: { fontSize: 15, fontFamily: 'Outfit_400Regular', color: '#666', textAlign: 'center', lineHeight: 22, marginBottom: 40 },
errorBox: { width: '100%', padding: 16, backgroundColor: '#F5F5F5', borderRadius: 12, marginBottom: 30 },
errorText: { fontSize: 12, fontFamily: 'Monaco', color: '#888' },
btn: { width: '100%', height: 56, borderRadius: 16, backgroundColor: '#1A1A1A', alignItems: 'center', justifyContent: 'center' },
btnText: { color: '#FFFFFF', fontSize: 16, fontFamily: 'Outfit_700Bold' },
});
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import { BlurView } from 'expo-blur';
import { View, StyleSheet, ViewStyle, Platform } from 'react-native';
import { Theme } from '../constants/theme';
import { useAppTheme } from '../context/ThemeContext';
interface GlassViewProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: number;
}
export const GlassView: React.FC<GlassViewProps> = ({ children, style, intensity = 20 }) => {
const { isDark, colors } = useAppTheme();
return (
<View style={[
styles.container,
{ backgroundColor: colors.glass, borderColor: colors.border },
style
]}>
{Platform.OS !== 'android' ? (
<BlurView
intensity={intensity}
tint={isDark ? 'dark' : 'light'}
style={StyleSheet.absoluteFill}
/>
) : null}
{children}
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: Theme.radius.lg,
overflow: 'hidden',
borderWidth: 1,
},
});
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { View, TextInput, Text, StyleSheet, ViewStyle, TouchableOpacity, Platform } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
import { Feather } from '@expo/vector-icons';
interface InputProps {
label?: string;
placeholder?: string;
value: string;
onChangeText: (text: string) => void;
secureTextEntry?: boolean;
style?: ViewStyle;
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
multiline?: boolean;
required?: boolean;
infoText?: string;
}
const LIME = '#C6F135';
export const Input: React.FC<InputProps> = ({
label,
placeholder,
value,
onChangeText,
secureTextEntry,
style,
keyboardType = 'default',
multiline = false,
required = false,
infoText
}) => {
const { colors, isDark } = useAppTheme();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const isSecure = secureTextEntry && !isPasswordVisible;
const bg = isDark ? '#1A1A1A' : '#F5F5F5';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<View style={[styles.container, style]}>
{label && (
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
{required && <Text style={{ color: '#EF4444' }}> *</Text>}
</Text>
</View>
)}
<View style={[
styles.inputWrapper,
{
backgroundColor: bg,
borderColor: isFocused ? LIME : border,
},
multiline && { height: 120, borderRadius: 20, alignItems: 'flex-start', paddingTop: 16 }
]}>
<TextInput
style={[
styles.input,
{ color: colors.text, paddingRight: secureTextEntry ? 45 : 16 },
Platform.OS === 'web' && { outlineStyle: 'none' } as any
]}
placeholder={placeholder}
placeholderTextColor={isDark ? '#444' : '#BBB'}
value={value}
onChangeText={onChangeText}
secureTextEntry={isSecure}
onFocus={handleFocus}
onBlur={handleBlur}
keyboardType={keyboardType}
autoCapitalize="none"
multiline={multiline}
textAlignVertical={multiline ? 'top' : 'center'}
/>
{secureTextEntry && (
<TouchableOpacity
style={styles.eyeIcon}
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
>
<Feather
name={isPasswordVisible ? "eye" : "eye-off"}
size={18}
color={isDark ? '#555' : '#AAA'}
/>
</TouchableOpacity>
)}
</View>
{infoText && <Text style={[styles.infoText, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>{infoText}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 18,
width: '100%',
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 8,
},
label: {
fontSize: 11,
fontFamily: 'Outfit_700Bold',
letterSpacing: 1,
textTransform: 'uppercase',
},
inputWrapper: {
height: 56,
borderRadius: 14,
paddingLeft: 16,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
},
input: {
flex: 1,
fontSize: 15,
fontFamily: 'Outfit_500Medium',
height: '100%',
},
eyeIcon: {
position: 'absolute',
right: 15,
height: '100%',
justifyContent: 'center',
paddingHorizontal: 5,
},
infoText: {
fontSize: 12,
marginTop: 6,
marginLeft: 4,
fontFamily: 'Outfit_400Regular',
},
});
+146
View File
@@ -0,0 +1,146 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, Linking } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
interface KillSwitchProps {
visible: boolean;
message?: string;
supportEmail?: string;
}
export const KillSwitchOverlay = ({ visible, message, supportEmail }: KillSwitchProps) => {
const { colors, isDark } = useAppTheme();
return (
<Modal visible={visible} transparent animationType="fade">
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#F8FAFC' }]}>
<View style={styles.content}>
<View style={[styles.glow, { backgroundColor: colors.error, opacity: 0.1 }]} />
<View style={[styles.iconContainer, { backgroundColor: isDark ? '#1A1A1A' : '#FFF' }]}>
<View style={[styles.iconBox, { backgroundColor: `${colors.error}15` }]}>
<Feather name="shield-off" size={48} color={colors.error} />
</View>
</View>
<Text style={[styles.title, { color: colors.text }]}>System Maintenance</Text>
<View style={styles.messageBox}>
<Text style={[styles.message, { color: isDark ? '#94A3B8' : '#64748B' }]}>
{message || "We're currently performing urgent system maintenance to improve your experience. Please check back later."}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: isDark ? '#1E293B' : '#F1F5F9' }]}>
<View style={[styles.dot, { backgroundColor: colors.error }]} />
<Text style={[styles.statusText, { color: colors.textSecondary }]}>
Service Temporarily Unavailable
</Text>
</View>
{supportEmail && (
<TouchableOpacity
activeOpacity={0.7}
style={[styles.supportBtn, { backgroundColor: isDark ? '#FFF' : '#111' }]}
onPress={() => Linking.openURL(`mailto:${supportEmail}`)}
>
<Feather name="mail" size={16} color={isDark ? '#000' : '#FFF'} style={{ marginRight: 8 }} />
<Text style={[styles.supportText, { color: isDark ? '#000' : '#FFF' }]}>Contact Support</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
glow: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
top: '10%',
},
content: {
alignItems: 'center',
width: '100%',
},
iconContainer: {
padding: 20,
borderRadius: 40,
marginBottom: 32,
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.1,
shadowRadius: 20,
},
iconBox: {
width: 100,
height: 100,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 32,
fontFamily: 'Outfit_800ExtraBold',
textAlign: 'center',
marginBottom: 20,
letterSpacing: -0.5,
},
messageBox: {
paddingHorizontal: 10,
marginBottom: 40,
},
message: {
fontSize: 16,
fontFamily: 'Outfit_400Regular',
textAlign: 'center',
lineHeight: 26,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 50,
gap: 10,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
fontSize: 13,
fontFamily: 'Outfit_600SemiBold',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
supportBtn: {
marginTop: 60,
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 20,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
},
supportText: {
fontSize: 15,
fontFamily: 'Outfit_700Bold',
},
});
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { View, ActivityIndicator, StyleSheet, Modal, Dimensions } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
interface LoadingOverlayProps {
visible: boolean;
}
const { width } = Dimensions.get('window');
const LIME = '#C6F135';
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible }) => {
const { isDark } = useAppTheme();
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<Modal transparent visible={visible} animationType="fade">
<View style={styles.container}>
<View style={[
styles.card,
{ backgroundColor: cardBg, borderColor: border }
]}>
<ActivityIndicator size="large" color={LIME} />
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
justifyContent: 'center',
alignItems: 'center',
},
card: {
width: 90,
height: 90,
borderRadius: 24,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 10,
},
});
+69
View File
@@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Platform, Animated } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export const NetworkStatus = () => {
const [isConnected, setIsConnected] = useState<boolean | null>(true);
const insets = useSafeAreaInsets();
const opacityAnim = useState(new Animated.Value(1))[0];
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
const connected = !!state.isConnected && !!state.isInternetReachable;
if (isConnected !== connected) {
setIsConnected(connected);
}
});
return () => unsubscribe();
}, [isConnected]);
// Optionally fade it out if online? Or keep it permanently. Let's keep it permanently but subtle if online.
const isOnline = isConnected !== false;
return (
<View style={[styles.container, { top: Math.max(insets.top, Platform.OS === 'ios' ? 44 : 10) }]} pointerEvents="none">
<Animated.View style={[
styles.pill,
{
backgroundColor: isOnline ? 'rgba(52, 199, 89, 0.15)' : 'rgba(255, 59, 48, 0.15)',
borderColor: isOnline ? 'rgba(52, 199, 89, 0.3)' : 'rgba(255, 59, 48, 0.3)',
}
]}>
<View style={[styles.dot, { backgroundColor: isOnline ? '#34C759' : '#FF3B30' }]} />
<Text style={[styles.text, { color: isOnline ? '#34C759' : '#FF3B30' }]}>
{isOnline ? 'Online' : 'Offline'}
</Text>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 24,
zIndex: 9999,
},
pill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
marginRight: 6,
},
text: {
fontSize: 10,
fontWeight: '700',
fontFamily: 'Outfit_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
});
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, ScrollView, Pressable, Platform, Animated, Easing } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
interface PopupProps {
visible: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
showCloseBtn?: boolean;
type?: 'center' | 'bottom';
}
export const Popup: React.FC<PopupProps> = ({
visible,
onClose,
title,
children,
showCloseBtn = true,
type = 'center'
}) => {
const { colors, isDark } = useAppTheme();
const [shouldRender, setShouldRender] = useState(visible);
const opacity = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(type === 'center' ? 0.9 : 1)).current;
const translateY = useRef(new Animated.Value(type === 'bottom' ? 600 : 0)).current;
useEffect(() => {
if (visible) {
setShouldRender(true);
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }),
type === 'center'
? Animated.spring(scale, { toValue: 1, damping: 15, stiffness: 100, useNativeDriver: true })
: Animated.spring(translateY, { toValue: 0, damping: 20, stiffness: 90, useNativeDriver: true })
]).start();
} else {
Animated.parallel([
Animated.timing(opacity, { toValue: 0, duration: 250, useNativeDriver: true }),
type === 'center'
? Animated.timing(scale, { toValue: 0.95, duration: 250, useNativeDriver: true })
: Animated.timing(translateY, { toValue: 600, duration: 250, useNativeDriver: true })
]).start(() => {
setShouldRender(false);
});
}
}, [visible, type]);
if (!shouldRender) return null;
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<Modal visible={shouldRender} transparent statusBarTranslucent animationType="none" onRequestClose={onClose}>
<View style={[
styles.overlay,
type === 'bottom' && { justifyContent: 'flex-end', padding: 0 }
]}>
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.7)', opacity }]} />
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
<Animated.View style={[
styles.content,
{
backgroundColor: cardBg,
borderColor: border,
borderWidth: 1,
opacity,
transform: [
{ scale: type === 'center' ? scale : 1 },
{ translateY: type === 'bottom' ? translateY : 0 }
]
},
type === 'bottom' && {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
borderTopLeftRadius: 36,
borderTopRightRadius: 36,
maxWidth: '100%',
maxHeight: '92%',
}
]}>
{type === 'bottom' && (
<View style={[styles.handle, { backgroundColor: isDark ? '#333' : '#E0E0E0' }]} />
)}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
{showCloseBtn && (
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5' }]}>
<Feather name="x" size={18} color={isDark ? '#FFF' : '#1A1A1A'} />
</TouchableOpacity>
)}
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
content: { width: '100%', maxWidth: 420, maxHeight: '85%', borderRadius: 28, overflow: 'hidden', elevation: 20 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 12 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24, paddingTop: 24, paddingBottom: 16 },
title: { fontSize: 22, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
closeBtn: { width: 34, height: 34, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
scrollContent: { paddingHorizontal: 24, paddingBottom: Platform.OS === 'ios' ? 40 : 24 },
});
+303
View File
@@ -0,0 +1,303 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
Dimensions,
ActivityIndicator,
Platform,
Animated,
Easing
} from 'react-native';
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialCommunityIcons, Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
const { width } = Dimensions.get('window');
// ── AICard: Premium Clean Card ───────────────────────
export const AICard = ({ children, style, delay = 0, variant = 'white' }: any) => {
const { isDark, colors } = useAppTheme();
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(30)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
delay,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 500,
delay,
easing: Easing.out(Easing.back(1.5)),
useNativeDriver: true,
})
]).start();
}, [delay]);
let bg: string;
if (variant === 'white') bg = colors.surface;
else if (variant === 'lime') bg = colors.primary;
else if (variant === 'dark') bg = colors.secondary;
else bg = variant;
return (
<Animated.View
style={[
styles.card,
{
backgroundColor: bg,
borderColor: colors.border,
shadowColor: isDark ? '#000' : '#1A1A1A',
opacity: fadeAnim,
transform: [{ translateY: slideAnim }]
},
style
]}
>
{children}
</Animated.View>
);
};
// ── AIButton: High-End CTA Button ────────────────────
export const AIButton = ({ title, onPress, loading, icon, color, style, textStyle }: any) => {
const { isDark, colors } = useAppTheme();
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
if (onPress) onPress();
};
const defaultBg = colors.primary;
const defaultText = colors.secondary;
const btnBg = color || defaultBg;
const isLimeBg = btnBg === colors.primary;
const resolvedTextColor = textStyle?.color || (isLimeBg ? colors.secondary : defaultText);
return (
<TouchableOpacity
onPress={handlePress}
disabled={loading}
activeOpacity={0.82}
style={[
styles.button,
{ backgroundColor: btnBg },
style
]}
>
{loading ? (
<ActivityIndicator color={resolvedTextColor} />
) : (
<View style={styles.buttonContent}>
{icon && (
<MaterialCommunityIcons
name={icon}
size={20}
color={resolvedTextColor}
style={{ marginRight: 8 }}
/>
)}
<Text style={[styles.buttonText, { color: resolvedTextColor }, textStyle]}>{title}</Text>
</View>
)}
</TouchableOpacity>
);
};
// ── AIInput: Themed Input Field ──────────────────────
export const AIInput = ({
label, icon, placeholder, value, onChangeText,
secure, isPassword, style, keyboardType, autoCapitalize = 'none'
}: any) => {
const { colors } = useAppTheme();
const [showPass, setShowPass] = React.useState(false);
const isSecure = secure || isPassword;
return (
<View style={[styles.inputGroup, style]}>
{label && (
<Text style={[styles.inputLabel, { color: colors.textSecondary }]}>{label}</Text>
)}
<View style={[styles.inputField, {
backgroundColor: colors.background,
borderColor: colors.border,
}]}>
{icon && (
<Feather
name={icon}
size={18}
color={colors.textPlaceholder}
style={{ marginRight: 12 }}
/>
)}
<TextInput
placeholder={placeholder}
placeholderTextColor={colors.textPlaceholder}
value={value}
onChangeText={onChangeText}
secureTextEntry={isSecure && !showPass}
style={[styles.textInput, { color: colors.text }]}
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
/>
{isSecure && (
<TouchableOpacity onPress={() => setShowPass(!showPass)} style={{ padding: 8 }}>
<Feather
name={showPass ? 'eye-off' : 'eye'}
size={18}
color={colors.textPlaceholder}
/>
</TouchableOpacity>
)}
</View>
</View>
);
};
// ── AISectionHeader: Section Title ───────────────────
export const AISectionHeader = ({ title, subtitle, action, onAction, style }: any) => {
const { colors } = useAppTheme();
return (
<View style={[styles.sectionHeader, style]}>
<View style={{ flex: 1 }}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{title}</Text>
{subtitle && (
<Text style={[styles.sectionSub, { color: colors.textSecondary }]}>{subtitle}</Text>
)}
</View>
{action && (
<TouchableOpacity onPress={onAction}>
<Text style={[styles.sectionAction, { color: colors.primary }]}>{action}</Text>
</TouchableOpacity>
)}
</View>
);
};
// ── AILimeBadge: Pill badge with lime accent ──────────
export const AILimeBadge = ({ label, style }: any) => {
const { colors } = useAppTheme();
return (
<View style={[styles.limeBadge, { backgroundColor: colors.primaryMuted }, style]}>
<Text style={[styles.limeBadgeText, { color: colors.primary }]}>{label}</Text>
</View>
);
};
// ── AISkeleton: Premium Shimmer Loader ────────────────
export const AISkeleton = ({ width, height, radius = 12, style }: any) => {
const { isDark, colors } = useAppTheme();
const shimmerAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.loop(
Animated.timing(shimmerAnim, {
toValue: 1,
duration: 1500,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
}, []);
const translateX = shimmerAnim.interpolate({
inputRange: [0, 1],
outputRange: [-300, 300]
});
const bg = colors.surface;
const highlight = colors.surfaceElevated;
return (
<View
style={[
{
width: width || '100%',
height: height || 20,
borderRadius: radius,
backgroundColor: bg,
overflow: 'hidden',
},
style || {},
]}
>
<Animated.View style={[{ width: '100%', height: '100%', transform: [{ translateX }] }]}>
<LinearGradient
colors={[bg, highlight, bg]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={{ flex: 1 }}
/>
</Animated.View>
</View>
);
};
export const AIPressable = ({ children, onPress, style, containerStyle }: any) => {
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Animated.spring(scale, { toValue: 0.97, damping: 10, stiffness: 300, useNativeDriver: true }).start();
};
const handlePressOut = () => {
Animated.spring(scale, { toValue: 1, damping: 10, stiffness: 300, useNativeDriver: true }).start();
};
return (
<TouchableOpacity
activeOpacity={1}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={onPress}
style={style}
>
<Animated.View style={[containerStyle || { flex: 1 }, { transform: [{ scale }] }]}>
{children}
</Animated.View>
</TouchableOpacity>
);
};
// ── AISuccess: Animated Checkmark ──────────────────
export const AISuccess = ({ size = 80 }: { size?: number }) => {
const { colors } = useAppTheme();
const scale = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.spring(scale, { toValue: 1, damping: 12, stiffness: 200, useNativeDriver: true }).start();
}, []);
return (
<Animated.View style={[styles.successCircle, { width: size, height: size, backgroundColor: colors.primary + '20', transform: [{ scale }] }]}>
<Feather name="check" size={size * 0.6} color={colors.primary} />
</Animated.View>
);
};
const styles = StyleSheet.create({
card: { borderRadius: 24, borderWidth: 1, padding: 20, overflow: 'hidden', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.06, shadowRadius: 16, elevation: 3 },
button: { height: 58, borderRadius: 16, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 },
buttonContent: { flexDirection: 'row', alignItems: 'center' },
buttonText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
inputGroup: { marginBottom: 18 },
inputLabel: { fontSize: 12, fontFamily: 'Outfit_600SemiBold', marginBottom: 8, marginLeft: 2, textTransform: 'uppercase', letterSpacing: 0.5 },
inputField: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 14, borderWidth: 1, paddingHorizontal: 16 },
textInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, marginBottom: 14 },
sectionTitle: { fontSize: 20, fontFamily: 'Outfit_700Bold' },
sectionSub: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 2 },
sectionAction: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
limeBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
limeBadgeText: { fontSize: 11, fontFamily: 'Outfit_700Bold', textTransform: 'uppercase', letterSpacing: 0.5 },
successCircle: { borderRadius: 100, alignItems: 'center', justifyContent: 'center' },
});
+25
View File
@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function HelloWave() {
return (
<View>
<Text style={styles.text}>👋</Text>
</View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 28,
lineHeight: 32,
marginTop: -6,
},
});
@@ -0,0 +1,41 @@
import React from 'react';
import { StyleSheet, View, ScrollView } from 'react-native';
interface Props {
children: React.ReactNode;
headerImage: React.ReactElement;
headerBackgroundColor: { dark: string; light: string };
}
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
return (
<View style={styles.container}>
<ScrollView scrollEventThrottle={16}>
<View style={[styles.header, { backgroundColor: headerBackgroundColor.light }]}>
{headerImage}
</View>
<View style={styles.content}>{children}</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 250,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
+60
View File
@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
+14
View File
@@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
+45
View File
@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
+32
View File
@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}