Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface AppHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
rightContent?: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
const AppHeaderComponent = ({
|
||||
title,
|
||||
subtitle,
|
||||
rightContent,
|
||||
showBackButton,
|
||||
}: AppHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation();
|
||||
const shouldShowBackButton = showBackButton ?? navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.leading}>
|
||||
{shouldShowBackButton ? (
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
style={[
|
||||
styles.backButton,
|
||||
{
|
||||
backgroundColor: theme.colors.cardGlass,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.pill,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={20} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{/* {subtitle ? (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{subtitle}</Text>
|
||||
) : null} */}
|
||||
</View>
|
||||
</View>
|
||||
{rightContent}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
leading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
copy: {
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
lineHeight: 20,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
backButton: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export const AppHeader = memo(AppHeaderComponent);
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { AccountSummary } from '../../types/app';
|
||||
import { useTheme } from '../../theme';
|
||||
import { formatCurrency } from '../../utils/formatters';
|
||||
|
||||
interface BalanceCardProps {
|
||||
account: AccountSummary;
|
||||
secondaryLabel?: string;
|
||||
}
|
||||
|
||||
const BalanceCardComponent = ({ account, secondaryLabel = 'SAVINGS ACCOUNT' }: BalanceCardProps) => {
|
||||
const theme = useTheme();
|
||||
const [isBalanceVisible, setIsBalanceVisible] = useState(true);
|
||||
|
||||
const formattedBalance = formatCurrency(account.availableBalance).replace('₹', '');
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={['#124577', '#124577', '#124577']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
|
||||
>
|
||||
{/* Decorative Brand Accent */}
|
||||
<View style={styles.brandAccent} />
|
||||
|
||||
<View style={styles.topRow}>
|
||||
<View style={styles.bankInfo}>
|
||||
<View style={styles.bankIconFrame}>
|
||||
<Ionicons name="business" size={20} color="white" />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.bankName}>CBS Bank</Text>
|
||||
<Text style={styles.cardType}>{secondaryLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{account.isPrimary && (
|
||||
<View style={styles.primaryBadge}>
|
||||
<Text style={styles.primaryBadgeText}>PRIMARY</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.mainContent}>
|
||||
<Text style={styles.maskedNumber}>A/c {account.maskedNumber}</Text>
|
||||
<View style={styles.balanceContainer}>
|
||||
<Text style={styles.balanceLabel}>AVAILABLE BALANCE</Text>
|
||||
<View style={styles.amountRow}>
|
||||
<Text style={styles.amountValue}>
|
||||
{isBalanceVisible ? `₹${formattedBalance}` : '₹ ••••••'}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setIsBalanceVisible((currentValue) => !currentValue)}
|
||||
style={styles.visibilityButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons
|
||||
name={isBalanceVisible ? 'eye-outline' : 'eye-off-outline'}
|
||||
size={20}
|
||||
color="rgba(255, 255, 255, 0.7)"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.footerItem}>
|
||||
<Text style={styles.footerLabel}>ACCOUNT HOLDER</Text>
|
||||
<Text style={styles.footerValue}>{account.accountHolder}</Text>
|
||||
</View>
|
||||
<View style={[styles.footerItem, styles.footerItemEnd]}>
|
||||
<Text style={styles.footerLabel}>UPI STATUS</Text>
|
||||
<View style={styles.upiRow}>
|
||||
<View style={styles.upiDot} />
|
||||
<Text style={styles.footerValue}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 24,
|
||||
gap: 32,
|
||||
position: 'relative',
|
||||
minHeight: 260,
|
||||
},
|
||||
gradientCard: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
brandAccent: {
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
bankInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
bankIconFrame: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
bankName: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
cardType: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
primaryBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
primaryBadgeText: {
|
||||
color: '#124577',
|
||||
fontSize: 10,
|
||||
fontWeight: '900',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
mainContent: {
|
||||
gap: 20,
|
||||
},
|
||||
maskedNumber: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
balanceContainer: {
|
||||
gap: 4,
|
||||
},
|
||||
balanceLabel: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
amountRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
amountValue: {
|
||||
color: 'white',
|
||||
fontSize: 36,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
},
|
||||
visibilityButton: {
|
||||
marginLeft: 12,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 4,
|
||||
},
|
||||
footerItem: {
|
||||
gap: 4,
|
||||
},
|
||||
footerItemEnd: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
footerLabel: {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: 9,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
footerValue: {
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
upiRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
upiDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#35D6A3',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const BalanceCard = memo(BalanceCardComponent);
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface BottomSheetProps {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BottomSheet = ({ visible, title, onClose, children }: BottomSheetProps) => {
|
||||
const theme = useTheme();
|
||||
const translateY = useRef(new Animated.Value(320)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(translateY, {
|
||||
toValue: visible ? 0 : 320,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [translateY, visible]);
|
||||
|
||||
const handleBackdropPress = () => {
|
||||
Keyboard.dismiss();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.flex}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={handleBackdropPress}>
|
||||
<View style={[styles.backdrop, { backgroundColor: theme.colors.overlay }]}>
|
||||
<TouchableWithoutFeedback onPress={() => {}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderTopLeftRadius: theme.radius.lg,
|
||||
borderTopRightRadius: theme.radius.lg,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.handle, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
{title ? (
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
) : null}
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
contentContainerStyle={styles.sheetScroll}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 28,
|
||||
gap: 12,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
sheetScroll: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
handle: {
|
||||
width: 42,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
import { haptics } from '../../services/haptics/haptics';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'cyber';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress?: (event: GestureResponderEvent) => void;
|
||||
variant?: ButtonVariant;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ButtonComponent = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = true,
|
||||
style,
|
||||
leftAccessory,
|
||||
}: ButtonProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const handlePress = (e: GestureResponderEvent) => {
|
||||
if (!disabled && !loading) {
|
||||
haptics.selection();
|
||||
onPress?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
const isSolid = variant === 'primary' || variant === 'danger' || variant === 'cyber';
|
||||
|
||||
|
||||
const containerStyle = [
|
||||
styles.button,
|
||||
{
|
||||
borderRadius: theme.radius.md,
|
||||
borderColor:
|
||||
variant === 'ghost'
|
||||
? 'transparent'
|
||||
: variant === 'secondary'
|
||||
? theme.colors.cardBorder
|
||||
: variant === 'cyber'
|
||||
? theme.colors.cyberCyan + '30'
|
||||
: 'transparent',
|
||||
backgroundColor:
|
||||
variant === 'secondary' ? theme.colors.cardGlass : 'transparent',
|
||||
width: fullWidth ? ('100%' as const) : undefined,
|
||||
opacity: disabled || loading ? 0.6 : 1,
|
||||
},
|
||||
variant === 'cyber' && {
|
||||
shadowColor: theme.colors.cyberCyan,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
style,
|
||||
];
|
||||
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={theme.colors.white} />
|
||||
) : (
|
||||
<>
|
||||
{leftAccessory}
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color:
|
||||
variant === 'ghost'
|
||||
? theme.colors.textPrimary
|
||||
: variant === 'secondary' ? theme.colors.textPrimary : theme.colors.white,
|
||||
letterSpacing: variant === 'cyber' ? 2 : 0,
|
||||
textTransform: variant === 'cyber' ? 'uppercase' : 'none',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable disabled={disabled || loading} onPress={handlePress} style={containerStyle}>
|
||||
{isSolid ? (
|
||||
<LinearGradient
|
||||
colors={
|
||||
variant === 'danger'
|
||||
? [theme.colors.danger, '#FF586E']
|
||||
: variant === 'cyber'
|
||||
? ['#2563EB', '#3B82F6']
|
||||
: [theme.colors.primary, theme.colors.primary]
|
||||
}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.gradient,
|
||||
{
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
</LinearGradient>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
},
|
||||
content: {
|
||||
minHeight: 54,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export const Button = memo(ButtonComponent);
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { memo, type PropsWithChildren } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface CardProps extends PropsWithChildren {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
variant?: 'default' | 'glass';
|
||||
}
|
||||
|
||||
const CardComponent = ({ children, style, variant = 'default' }: CardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
theme.shadows.soft,
|
||||
{
|
||||
backgroundColor: variant === 'glass' ? theme.colors.cardGlass : theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.lg,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
padding: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export const Card = memo(CardComponent);
|
||||
@@ -0,0 +1,637 @@
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
PanResponder,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const DAY_SIZE = Math.floor((SCREEN_WIDTH - 80) / 7);
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
const MONTHS_SHORT = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
// Generate year range: 1950 → current year + 10
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_RANGE_START = 1950;
|
||||
const YEAR_RANGE_END = CURRENT_YEAR + 10;
|
||||
const YEARS = Array.from({ length: YEAR_RANGE_END - YEAR_RANGE_START + 1 }, (_, i) => YEAR_RANGE_START + i);
|
||||
|
||||
interface DatePickerProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value?: string; // DD/MM/YYYY
|
||||
onDateChange: (dateStr: string) => void;
|
||||
error?: string;
|
||||
/** Max selectable date. Defaults to today */
|
||||
maxDate?: Date;
|
||||
/** Min selectable date */
|
||||
minDate?: Date;
|
||||
}
|
||||
|
||||
type PickerMode = 'days' | 'months' | 'years';
|
||||
|
||||
const parseDDMMYYYY = (str: string | undefined): Date | null => {
|
||||
if (!str) return null;
|
||||
const parts = str.split('/');
|
||||
if (parts.length !== 3) return null;
|
||||
const d = parseInt(parts[0], 10);
|
||||
const m = parseInt(parts[1], 10) - 1;
|
||||
const y = parseInt(parts[2], 10);
|
||||
if (isNaN(d) || isNaN(m) || isNaN(y)) return null;
|
||||
return new Date(y, m, d);
|
||||
};
|
||||
|
||||
const formatDDMMYYYY = (date: Date): string => {
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const y = date.getFullYear();
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
const isSameDay = (a: Date, b: Date) =>
|
||||
a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||
|
||||
export const DatePicker = ({
|
||||
label,
|
||||
placeholder = 'DD/MM/YYYY',
|
||||
value,
|
||||
onDateChange,
|
||||
error,
|
||||
maxDate,
|
||||
minDate,
|
||||
}: DatePickerProps) => {
|
||||
const theme = useTheme();
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
const selectedDate = useMemo(() => parseDDMMYYYY(value), [value]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [viewMonth, setViewMonth] = useState(selectedDate?.getMonth() ?? today.getMonth());
|
||||
const [viewYear, setViewYear] = useState(selectedDate?.getFullYear() ?? today.getFullYear());
|
||||
const [pickerMode, setPickerMode] = useState<PickerMode>('days');
|
||||
|
||||
const yearScrollRef = useRef<ScrollView>(null);
|
||||
|
||||
const openCalendar = useCallback(() => {
|
||||
const ref = selectedDate ?? today;
|
||||
setViewMonth(ref.getMonth());
|
||||
setViewYear(ref.getFullYear());
|
||||
setPickerMode('days');
|
||||
setVisible(true);
|
||||
}, [selectedDate, today]);
|
||||
|
||||
// ── Month navigation ──────────────────────────────────────
|
||||
const prevMonth = useCallback(() => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear(y => y - 1);
|
||||
} else {
|
||||
setViewMonth(m => m - 1);
|
||||
}
|
||||
}, [viewMonth]);
|
||||
|
||||
const nextMonth = useCallback(() => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear(y => y + 1);
|
||||
} else {
|
||||
setViewMonth(m => m + 1);
|
||||
}
|
||||
}, [viewMonth]);
|
||||
|
||||
// ── Swipe handler (PanResponder) ──────────────────────────
|
||||
const swipeThreshold = 50;
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||
// Only capture horizontal swipes
|
||||
return Math.abs(gestureState.dx) > 15 && Math.abs(gestureState.dy) < 30;
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
if (gestureState.dx > swipeThreshold) {
|
||||
// Swipe right → previous month (functional updates avoid stale closure)
|
||||
setViewMonth(m => {
|
||||
if (m === 0) {
|
||||
setViewYear(y => y - 1);
|
||||
return 11;
|
||||
}
|
||||
return m - 1;
|
||||
});
|
||||
} else if (gestureState.dx < -swipeThreshold) {
|
||||
// Swipe left → next month
|
||||
setViewMonth(m => {
|
||||
if (m === 11) {
|
||||
setViewYear(y => y + 1);
|
||||
return 0;
|
||||
}
|
||||
return m + 1;
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
// ── Day calculations ──────────────────────────────────────
|
||||
const daysInMonth = useMemo(() => new Date(viewYear, viewMonth + 1, 0).getDate(), [viewYear, viewMonth]);
|
||||
const firstDayOfWeek = useMemo(() => new Date(viewYear, viewMonth, 1).getDay(), [viewYear, viewMonth]);
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const days: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDayOfWeek; i++) days.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||
return days;
|
||||
}, [daysInMonth, firstDayOfWeek]);
|
||||
|
||||
const isDayDisabled = useCallback(
|
||||
(day: number) => {
|
||||
const d = new Date(viewYear, viewMonth, day);
|
||||
if (maxDate && d > maxDate) return true;
|
||||
if (minDate && d < minDate) return true;
|
||||
return false;
|
||||
},
|
||||
[viewYear, viewMonth, maxDate, minDate],
|
||||
);
|
||||
|
||||
const handleSelect = (day: number) => {
|
||||
const d = new Date(viewYear, viewMonth, day);
|
||||
onDateChange(formatDDMMYYYY(d));
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
// ── Month picker handler ──────────────────────────────────
|
||||
const handleMonthSelect = (monthIndex: number) => {
|
||||
setViewMonth(monthIndex);
|
||||
setPickerMode('days');
|
||||
};
|
||||
|
||||
// ── Year picker handler ───────────────────────────────────
|
||||
const handleYearSelect = (year: number) => {
|
||||
setViewYear(year);
|
||||
setPickerMode('days');
|
||||
};
|
||||
|
||||
// Auto-scroll to current year when year picker opens
|
||||
const handleOpenYearPicker = useCallback(() => {
|
||||
setPickerMode('years');
|
||||
// Scroll to the current viewYear position after render
|
||||
setTimeout(() => {
|
||||
const yearIndex = YEARS.indexOf(viewYear);
|
||||
if (yearIndex >= 0 && yearScrollRef.current) {
|
||||
yearScrollRef.current.scrollTo({ y: Math.max(0, (yearIndex - 2) * 48), animated: false });
|
||||
}
|
||||
}, 100);
|
||||
}, [viewYear]);
|
||||
|
||||
// ── Render: Days grid ─────────────────────────────────────
|
||||
const renderDaysView = () => (
|
||||
<>
|
||||
{/* Header: tappable month & year */}
|
||||
<View style={styles.calendarHeader}>
|
||||
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name="chevron-back" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.monthYearWrap}>
|
||||
<Pressable onPress={() => setPickerMode('months')} style={styles.headerTappable}>
|
||||
<Text style={[styles.monthText, { color: theme.colors.primary }]}>{MONTHS[viewMonth]}</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleOpenYearPicker} style={styles.headerTappable}>
|
||||
<Text style={[styles.yearText, { color: theme.colors.textMuted }]}>{viewYear}</Text>
|
||||
<Ionicons name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<View style={styles.weekdayRow}>
|
||||
{WEEKDAYS.map(wd => (
|
||||
<View key={wd} style={[styles.dayCell, { width: DAY_SIZE, height: 32 }]}>
|
||||
<Text style={[styles.weekdayText, { color: theme.colors.textMuted }]}>{wd}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Days grid with swipe */}
|
||||
<View {...panResponder.panHandlers}>
|
||||
<View style={styles.daysGrid}>
|
||||
{calendarDays.map((day, idx) => {
|
||||
if (day === null) {
|
||||
return <View key={`empty-${idx}`} style={[styles.dayCell, { width: DAY_SIZE, height: DAY_SIZE }]} />;
|
||||
}
|
||||
|
||||
const dateObj = new Date(viewYear, viewMonth, day);
|
||||
const isToday = isSameDay(dateObj, today);
|
||||
const isSelected = selectedDate ? isSameDay(dateObj, selectedDate) : false;
|
||||
const disabled = isDayDisabled(day);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={day}
|
||||
disabled={disabled}
|
||||
onPress={() => handleSelect(day)}
|
||||
style={[
|
||||
styles.dayCell,
|
||||
{ width: DAY_SIZE, height: DAY_SIZE },
|
||||
isSelected && {
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: DAY_SIZE / 2,
|
||||
},
|
||||
isToday && !isSelected && {
|
||||
borderWidth: 1.5,
|
||||
borderColor: theme.colors.primary,
|
||||
borderRadius: DAY_SIZE / 2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayText,
|
||||
{ color: theme.colors.textPrimary },
|
||||
isSelected && { color: theme.colors.white, fontWeight: '800' },
|
||||
isToday && !isSelected && { color: theme.colors.primary, fontWeight: '700' },
|
||||
disabled && { color: theme.colors.textMuted, opacity: 0.4 },
|
||||
]}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.calendarFooter}>
|
||||
<Pressable onPress={() => setVisible(false)} style={[styles.footerBtn, { borderColor: theme.colors.cardBorder }]}>
|
||||
<Text style={[styles.footerBtnText, { color: theme.colors.textMuted }]}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setViewMonth(today.getMonth());
|
||||
setViewYear(today.getFullYear());
|
||||
handleSelect(today.getDate());
|
||||
}}
|
||||
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
|
||||
>
|
||||
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontWeight: '700' }]}>Today</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── Render: Month picker grid ─────────────────────────────
|
||||
const renderMonthsView = () => (
|
||||
<>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
|
||||
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Month</Text>
|
||||
<View style={{ width: 22 }} />
|
||||
</View>
|
||||
|
||||
<View style={styles.monthGrid}>
|
||||
{MONTHS_SHORT.map((m, idx) => {
|
||||
const isActive = idx === viewMonth;
|
||||
return (
|
||||
<Pressable
|
||||
key={m}
|
||||
onPress={() => handleMonthSelect(idx)}
|
||||
style={[
|
||||
styles.monthCell,
|
||||
{
|
||||
backgroundColor: isActive ? theme.colors.primary : `${theme.colors.primary}08`,
|
||||
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.monthCellText,
|
||||
{ color: isActive ? '#fff' : theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{m}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── Render: Year picker list ──────────────────────────────
|
||||
const renderYearsView = () => (
|
||||
<>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
|
||||
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Year</Text>
|
||||
<View style={{ width: 22 }} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
ref={yearScrollRef}
|
||||
style={styles.yearList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.yearListContent}
|
||||
>
|
||||
{YEARS.map(year => {
|
||||
const isActive = year === viewYear;
|
||||
return (
|
||||
<Pressable
|
||||
key={year}
|
||||
onPress={() => handleYearSelect(year)}
|
||||
style={[
|
||||
styles.yearRow,
|
||||
{
|
||||
backgroundColor: isActive ? `${theme.colors.primary}12` : 'transparent',
|
||||
borderColor: isActive ? theme.colors.primary : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.yearRowText,
|
||||
{
|
||||
color: isActive ? theme.colors.primary : theme.colors.textPrimary,
|
||||
fontWeight: isActive ? '800' : '500',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{year}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<Ionicons name="checkmark" size={20} color={theme.colors.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && (
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
|
||||
{label.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Tappable field */}
|
||||
<Pressable
|
||||
onPress={openCalendar}
|
||||
style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
borderColor: error ? theme.colors.error : theme.colors.cardBorder,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
|
||||
<Text
|
||||
style={[
|
||||
styles.inputText,
|
||||
{ color: value ? theme.colors.textPrimary : theme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
|
||||
|
||||
{/* Calendar Modal */}
|
||||
<Modal transparent visible={visible} animationType="fade" onRequestClose={() => setVisible(false)}>
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setVisible(false)}>
|
||||
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.calendarCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
onPress={() => {}} // prevent dismiss when tapping inside
|
||||
>
|
||||
{pickerMode === 'days' && renderDaysView()}
|
||||
{pickerMode === 'months' && renderMonthsView()}
|
||||
{pickerMode === 'years' && renderYearsView()}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
marginLeft: 4,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 52,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
inputText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
marginLeft: 4,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
calendarCard: {
|
||||
width: SCREEN_WIDTH - 40,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
|
||||
// ── Header ───────────────────────────────────
|
||||
calendarHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
navBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthYearWrap: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTappable: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
monthText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
yearText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// ── Days grid ────────────────────────────────
|
||||
weekdayRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: 4,
|
||||
},
|
||||
weekdayText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
daysGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
dayCell: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// ── Footer ───────────────────────────────────
|
||||
calendarFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
footerBtn: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
todayBtn: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
footerBtnText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// ── Month / Year picker shared ───────────────
|
||||
pickerHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '800',
|
||||
},
|
||||
|
||||
// ── Month picker grid ────────────────────────
|
||||
monthGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthCell: {
|
||||
width: (SCREEN_WIDTH - 100) / 3,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthCellText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// ── Year picker list ─────────────────────────
|
||||
yearList: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
yearListContent: {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
yearRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
yearRowText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
const EmptyStateComponent = ({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: EmptyStateProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.icon,
|
||||
{
|
||||
backgroundColor: theme.colors.cardGlass,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{description}
|
||||
</Text>
|
||||
{actionLabel && onAction ? <Button title={actionLabel} onPress={onAction} /> : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
icon: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export const EmptyState = memo(EmptyStateComponent);
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type TextInputProps,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
interpolateColor,
|
||||
withRepeat,
|
||||
Easing
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
leftAccessory?: React.ReactNode;
|
||||
rightAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = ({
|
||||
label,
|
||||
error,
|
||||
leftAccessory,
|
||||
rightAccessory,
|
||||
...props
|
||||
}: InputProps) => {
|
||||
const theme = useTheme();
|
||||
const focused = useSharedValue(0);
|
||||
|
||||
const handleFocus = () => {
|
||||
focused.value = withTiming(1, { duration: 250 });
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
focused.value = withTiming(0, { duration: 250 });
|
||||
};
|
||||
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
borderColor: interpolateColor(
|
||||
focused.value,
|
||||
[0, 1],
|
||||
[theme.colors.cardBorder, theme.colors.primary]
|
||||
),
|
||||
backgroundColor: interpolateColor(
|
||||
focused.value,
|
||||
[0, 1],
|
||||
['rgba(0, 0, 0, 0.02)', '#FFFFFF']
|
||||
),
|
||||
transform: [{ scale: withTiming(focused.value === 1 ? 1.01 : 1, { duration: 200 }) }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && (
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
|
||||
{label.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
<Animated.View style={[styles.inputWrapper, containerAnimatedStyle]}>
|
||||
{leftAccessory && <View style={styles.accessory}>{leftAccessory}</View>}
|
||||
<TextInput
|
||||
{...props}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholderTextColor={theme.colors.textMuted}
|
||||
style={[styles.input, { color: theme.colors.textPrimary }]}
|
||||
/>
|
||||
{rightAccessory && <View style={styles.accessory}>{rightAccessory}</View>}
|
||||
</Animated.View>
|
||||
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
marginLeft: 4,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 52,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
height: '100%',
|
||||
},
|
||||
accessory: {
|
||||
marginRight: 10,
|
||||
opacity: 0.7,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type TextInput,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { useKeyboardHandler } from '../../hooks/useKeyboardHandler';
|
||||
|
||||
interface KeyboardAwareScrollViewProps {
|
||||
children: React.ReactNode;
|
||||
/** Style for the content container inside the scroll view */
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
/** Style for the scroll view wrapper */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Extra bottom padding to add when keyboard is visible (e.g. for footers) */
|
||||
extraPadding?: number;
|
||||
/** Whether to show scroll indicator */
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A drop-in ScrollView replacement that automatically adds bottom padding
|
||||
* when the keyboard opens, ensuring all inputs remain visible.
|
||||
*
|
||||
* Works reliably on BOTH iOS and Android without KeyboardAvoidingView.
|
||||
* Uses animated padding for smooth transitions.
|
||||
*/
|
||||
export const KeyboardAwareScrollView = ({
|
||||
children,
|
||||
contentContainerStyle,
|
||||
style,
|
||||
extraPadding = 40,
|
||||
showsVerticalScrollIndicator = false,
|
||||
}: KeyboardAwareScrollViewProps) => {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const { keyboardHeight, isVisible } = useKeyboardHandler();
|
||||
|
||||
// Calculate the dynamic padding: keyboard height + extra buffer
|
||||
const bottomPad = isVisible ? keyboardHeight + extraPadding : 0;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={[styles.flex, style]}
|
||||
contentContainerStyle={[
|
||||
contentContainerStyle,
|
||||
{ paddingBottom: (contentContainerStyle as any)?.paddingBottom || 40 + bottomPad },
|
||||
]}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
automaticallyAdjustKeyboardInsets={false}
|
||||
overScrollMode="always"
|
||||
bounces
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
interface KeyboardSafeViewProps {
|
||||
/**
|
||||
* Content to render inside the keyboard-safe container.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* If true, wraps children in a ScrollView that scrolls to the focused input.
|
||||
* Use for forms and long content screens.
|
||||
* @default false
|
||||
*/
|
||||
scrollable?: boolean;
|
||||
|
||||
/**
|
||||
* Extra vertical offset (e.g. for headers, tab bars, safe areas).
|
||||
* On iOS this is added to the KeyboardAvoidingView's keyboardVerticalOffset.
|
||||
* @default 0
|
||||
*/
|
||||
extraOffset?: number;
|
||||
|
||||
/**
|
||||
* Custom style for the outer container.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
|
||||
/**
|
||||
* Custom contentContainerStyle for the inner ScrollView (only used when scrollable=true).
|
||||
*/
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
|
||||
/**
|
||||
* Whether to dismiss the keyboard when tapping outside inputs.
|
||||
* @default true
|
||||
*/
|
||||
dismissOnTap?: boolean;
|
||||
|
||||
/**
|
||||
* Whether keyboard should persist taps on inner elements.
|
||||
* @default 'handled'
|
||||
*/
|
||||
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled';
|
||||
|
||||
/**
|
||||
* ScrollView keyboardDismissMode.
|
||||
* @default 'interactive'
|
||||
*/
|
||||
keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
|
||||
|
||||
/**
|
||||
* Whether to show the scroll indicator when scrollable.
|
||||
* @default false
|
||||
*/
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
|
||||
/**
|
||||
* Enable bounces on iOS ScrollView.
|
||||
* @default true
|
||||
*/
|
||||
bounces?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable keyboard-safe wrapper that handles:
|
||||
* - KeyboardAvoidingView with correct behavior for iOS/Android
|
||||
* - Optional ScrollView with auto-scroll to focused input
|
||||
* - Dismiss keyboard on outside tap
|
||||
* - Proper offsets for headers, tab bars, and safe areas
|
||||
*/
|
||||
export const KeyboardSafeView = ({
|
||||
children,
|
||||
scrollable = false,
|
||||
extraOffset = 0,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
dismissOnTap = true,
|
||||
keyboardShouldPersistTaps = 'handled',
|
||||
keyboardDismissMode = 'interactive',
|
||||
showsVerticalScrollIndicator = false,
|
||||
bounces = true,
|
||||
}: KeyboardSafeViewProps) => {
|
||||
// iOS: 'padding' works best, adds padding at the bottom
|
||||
// Android: 'height' or undefined works best (Android handles it natively in most cases)
|
||||
const behavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
|
||||
|
||||
// Base offset: accounts for navigation header height on iOS
|
||||
// Android's windowSoftInputMode handles most of the offset natively
|
||||
const baseOffset = Platform.OS === 'ios' ? 90 : 0;
|
||||
const totalOffset = baseOffset + extraOffset;
|
||||
|
||||
const content = scrollable ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContent, contentContainerStyle]}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||
keyboardDismissMode={keyboardDismissMode}
|
||||
bounces={bounces}
|
||||
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.flex, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const wrappedContent = dismissOnTap && !scrollable ? (
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
|
||||
{content}
|
||||
</TouchableWithoutFeedback>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={behavior}
|
||||
style={[styles.flex, style]}
|
||||
keyboardVerticalOffset={totalOffset}
|
||||
>
|
||||
{wrappedContent}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default KeyboardSafeView;
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface LoaderProps {
|
||||
size?: 'small' | 'large';
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const LoaderComponent = ({ size = 'large', style }: LoaderProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.loader, style]}>
|
||||
<ActivityIndicator color={theme.colors.accent} size={size} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface SkeletonBlockProps {
|
||||
height?: number;
|
||||
width?: number | `${number}%`;
|
||||
}
|
||||
|
||||
export const SkeletonBlock = ({ height = 18, width = '100%' }: SkeletonBlockProps) => {
|
||||
const theme = useTheme();
|
||||
const opacity = useRef(new Animated.Value(0.35)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0.75,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0.35,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
).start();
|
||||
}, [opacity]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
height,
|
||||
width,
|
||||
borderRadius: 999,
|
||||
backgroundColor: theme.colors.cardGlass,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loader: {
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export const Loader = memo(LoaderComponent);
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface AppModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppModal = ({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
children,
|
||||
}: AppModalProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleOverlayPress = () => {
|
||||
Keyboard.dismiss();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.flex}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={handleOverlayPress}>
|
||||
<View style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}>
|
||||
<TouchableWithoutFeedback onPress={() => {}}>
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderRadius: theme.radius.lg,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{description ? (
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
contentContainerStyle={styles.childrenScroll}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
content: {
|
||||
borderWidth: 1,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
childrenScroll: {
|
||||
flexGrow: 1,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
StatusBar,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
import { AppHeader } from '../AppHeader';
|
||||
import { TAB_BAR_HEIGHT } from '../../utils/layoutConstants';
|
||||
|
||||
interface ScreenShellProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
rightContent?: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
children: React.ReactNode;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
transparent?: boolean;
|
||||
/**
|
||||
* Extra keyboard vertical offset (e.g. for screens with custom headers or tab bars).
|
||||
* Added on top of the default offset.
|
||||
* @default 0
|
||||
*/
|
||||
keyboardExtraOffset?: number;
|
||||
/**
|
||||
* Whether to enable keyboard avoidance for this screen.
|
||||
* @default true
|
||||
*/
|
||||
keyboardAware?: boolean;
|
||||
/**
|
||||
* Whether this screen is rendered inside a bottom tab navigator
|
||||
* with an absolutely-positioned tab bar. When true, adds bottom
|
||||
* padding so content never hides behind the tab bar.
|
||||
* @default true
|
||||
*/
|
||||
withTabBar?: boolean;
|
||||
fixedFooter?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScreenShell = ({
|
||||
title,
|
||||
subtitle,
|
||||
rightContent,
|
||||
scrollable = true,
|
||||
children,
|
||||
contentContainerStyle,
|
||||
transparent = false,
|
||||
keyboardExtraOffset = 0,
|
||||
keyboardAware = true,
|
||||
withTabBar = true,
|
||||
fixedFooter,
|
||||
}: ScreenShellProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// iOS needs 'padding' behavior, Android handles it natively via windowSoftInputMode
|
||||
// but we use 'height' as a fallback for Android screens that need it
|
||||
const kavBehavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
|
||||
// Base offset accounts for the navigation header + safe area
|
||||
const baseOffset = Platform.OS === 'ios' ? 90 : 0;
|
||||
const totalOffset = baseOffset + keyboardExtraOffset;
|
||||
|
||||
// Bottom spacing: if inside tab navigator, add TAB_BAR_HEIGHT + breathing room
|
||||
// If there's a fixedFooter, we don't necessarily need tab bar padding on the scroll view,
|
||||
// but let's leave it for safety or adjust based on layout.
|
||||
const tabBarPadding = withTabBar ? TAB_BAR_HEIGHT + 16 : 20;
|
||||
|
||||
const scrollContent = (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: tabBarPadding },
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const staticContent = (
|
||||
<View style={styles.flex}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
styles.staticContent,
|
||||
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
|
||||
contentContainerStyle,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = scrollable ? scrollContent : staticContent;
|
||||
|
||||
const innerContent = keyboardAware ? (
|
||||
<KeyboardAvoidingView
|
||||
behavior={kavBehavior}
|
||||
style={styles.flex}
|
||||
keyboardVerticalOffset={totalOffset}
|
||||
>
|
||||
{content}
|
||||
</KeyboardAvoidingView>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
|
||||
const container = (
|
||||
<SafeAreaView edges={['top']} style={styles.flex}>
|
||||
{/* ✅ Status Bar Added */}
|
||||
<StatusBar
|
||||
barStyle={'dark-content'}
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
<View style={[styles.frame, transparent && styles.transparentFrame]}>
|
||||
{title ? (
|
||||
<AppHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{innerContent}
|
||||
{fixedFooter}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
if (transparent) {
|
||||
return container;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={theme.colors.backgroundGradient}
|
||||
style={styles.flex}
|
||||
>
|
||||
{container}
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
frame: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 10,
|
||||
gap: 18,
|
||||
},
|
||||
transparentFrame: {
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
content: {
|
||||
gap: 16,
|
||||
},
|
||||
staticContent: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default ScreenShell;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks';
|
||||
import { selectToasts } from '../../modules/ui/store/uiSelectors';
|
||||
import { uiActions } from '../../modules/ui/store/uiSlice';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface ToastCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const ToastCard = ({ id, title, description, type }: ToastCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
dispatch(uiActions.dismissToast(id));
|
||||
}, 3200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [dispatch, id]);
|
||||
|
||||
const accentColor =
|
||||
type === 'success'
|
||||
? theme.colors.success
|
||||
: type === 'error'
|
||||
? theme.colors.danger
|
||||
: theme.colors.info;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => dispatch(uiActions.dismissToast(id))}
|
||||
style={[
|
||||
styles.toast,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.accent, { backgroundColor: accentColor }]} />
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{description ? (
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToastHost = () => {
|
||||
const toasts = useAppSelector(selectToasts);
|
||||
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView pointerEvents="box-none" style={styles.host}>
|
||||
{toasts.slice(-3).map((toast) => (
|
||||
<ToastCard key={toast.id} {...toast} />
|
||||
))}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
host: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
paddingHorizontal: 20,
|
||||
gap: 10,
|
||||
},
|
||||
toast: {
|
||||
flexDirection: 'row',
|
||||
borderWidth: 1,
|
||||
padding: 14,
|
||||
gap: 12,
|
||||
},
|
||||
accent: {
|
||||
width: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import type { TransactionRecord } from '../../types/app';
|
||||
import { useTheme } from '../../theme';
|
||||
import { formatCurrency, formatDate } from '../../utils/formatters';
|
||||
|
||||
interface TransactionItemProps {
|
||||
item: TransactionRecord;
|
||||
}
|
||||
|
||||
type RiskLevel = 'safe' | 'medium' | 'high';
|
||||
|
||||
const TransactionItemComponent = ({ item }: TransactionItemProps) => {
|
||||
const theme = useTheme();
|
||||
const isCredit = item.direction === 'credit';
|
||||
|
||||
const riskLevel = useMemo<RiskLevel>(() => {
|
||||
const sum = item.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
if (sum % 10 < 7) return 'safe';
|
||||
if (sum % 10 < 9) return 'medium';
|
||||
return 'high';
|
||||
}, [item.id]);
|
||||
|
||||
const riskConfig = {
|
||||
safe: { color: theme.colors.success, label: 'SECURE', icon: 'shield-checkmark' as const },
|
||||
medium: { color: theme.colors.warning, label: 'FLAGGED', icon: 'warning' as const },
|
||||
high: { color: theme.colors.danger, label: 'HIGH RISK', icon: 'alert-circle' as const },
|
||||
}[riskLevel];
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.wrapper,
|
||||
{
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.leading}>
|
||||
<View
|
||||
style={[
|
||||
styles.icon,
|
||||
{
|
||||
backgroundColor: isCredit
|
||||
? `${theme.colors.success}10`
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: isCredit ? `${theme.colors.success}30` : 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={isCredit ? 'chevron-down' : 'chevron-up'}
|
||||
size={18}
|
||||
color={isCredit ? theme.colors.success : theme.colors.white}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.white }]}>{item.title}</Text>
|
||||
</View>
|
||||
<View style={styles.subtitleRow}>
|
||||
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
|
||||
{item.counterparty}
|
||||
</Text>
|
||||
<View style={styles.dot} />
|
||||
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
|
||||
{formatDate(item.occurredAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.trailing}>
|
||||
<Text
|
||||
style={[
|
||||
styles.amount,
|
||||
{ color: isCredit ? theme.colors.success : theme.colors.white },
|
||||
]}
|
||||
>
|
||||
{isCredit ? '+' : '-'}
|
||||
{formatCurrency(item.amount)}
|
||||
</Text>
|
||||
<View style={[styles.riskBadge, { backgroundColor: `${riskConfig.color}10`, borderColor: `${riskConfig.color}20` }]}>
|
||||
<Text style={[styles.riskText, { color: riskConfig.color }]}>{riskConfig.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 18,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
leading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
flex: 1,
|
||||
},
|
||||
icon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 23,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
copy: {
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
subtitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
dot: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
trailing: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
riskBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
},
|
||||
riskText: {
|
||||
fontSize: 8,
|
||||
fontWeight: '900',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export const TransactionItem = memo(TransactionItemComponent);
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from './AppHeader';
|
||||
export * from './BalanceCard';
|
||||
export * from './BottomSheet';
|
||||
export * from './Button';
|
||||
export * from './Card';
|
||||
export * from './DatePicker';
|
||||
export * from './EmptyState';
|
||||
export * from './Input';
|
||||
export * from './KeyboardAwareScrollView';
|
||||
export * from './KeyboardSafeView';
|
||||
export * from './Loader';
|
||||
export * from './Modal';
|
||||
export * from './ScreenShell';
|
||||
export * from './ToastHost';
|
||||
export * from './TransactionItem';
|
||||
Reference in New Issue
Block a user