Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
+97
View File
@@ -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);
+230
View File
@@ -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);
+112
View File
@@ -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',
},
});
+158
View File
@@ -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);
+45
View File
@@ -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);
+637
View File
@@ -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,
},
});
+69
View File
@@ -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);
+117
View File
@@ -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,
},
});
+148
View File
@@ -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;
+75
View File
@@ -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);
+109
View File
@@ -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,
},
});
+182
View File
@@ -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;
+115
View File
@@ -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,
},
});
+170
View File
@@ -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);
+15
View File
@@ -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';