feat: add expo mobile application source code
This commit is contained in:
@@ -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,
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user