diff options
| author | Lucas Faria Mendes <lucas.fariamo08@gmail.com> | 2026-08-14 18:06:35 +0000 |
|---|---|---|
| committer | Lucas Faria Mendes <lucas.fariamo08@gmail.com> | 2026-08-14 18:06:35 +0000 |
| commit | 2c2dfba45357bbd205a59145ae55a2779c911ae0 (patch) | |
| tree | 0736b848aed56ec2da3c2489c49da0519447703d /src/components | |
| download | yaba-2c2dfba45357bbd205a59145ae55a2779c911ae0.tar.gz yaba-2c2dfba45357bbd205a59145ae55a2779c911ae0.zip | |
remake
Signed-off-by: Lucas Faria Mendes <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/animated-splash-screen.tsx | 103 | ||||
| -rw-r--r-- | src/components/expense-row.tsx | 213 | ||||
| -rw-r--r-- | src/components/ui/action-modal.tsx | 107 | ||||
| -rw-r--r-- | src/components/ui/button.tsx | 141 | ||||
| -rw-r--r-- | src/components/ui/expenses-chart.tsx | 187 | ||||
| -rw-r--r-- | src/components/ui/input.tsx | 98 | ||||
| -rw-r--r-- | src/components/ui/options-modal.tsx | 137 | ||||
| -rw-r--r-- | src/components/ui/primitives.tsx | 104 |
8 files changed, 1090 insertions, 0 deletions
diff --git a/src/components/animated-splash-screen.tsx b/src/components/animated-splash-screen.tsx new file mode 100644 index 0000000..7ecfdef --- /dev/null +++ b/src/components/animated-splash-screen.tsx @@ -0,0 +1,103 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, View, Text } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + withSpring, + Easing, + runOnJS, + withDelay, +} from 'react-native-reanimated'; +import { Colors } from '@/constants/theme'; +import { LinearGradient } from 'expo-linear-gradient'; + +interface Props { + isAppReady: boolean; + onAnimationComplete: () => void; +} + +export function AnimatedSplashScreen({ isAppReady, onAnimationComplete }: Props) { + const opacity = useSharedValue(1); + const scale = useSharedValue(1); + const textOpacity = useSharedValue(0); + const textTranslateY = useSharedValue(20); + const logoScale = useSharedValue(0.8); + + useEffect(() => { + textOpacity.value = withTiming(1, { duration: 1000 }); + textTranslateY.value = withSpring(0, { damping: 14, stiffness: 100 }); + logoScale.value = withSpring(1, { damping: 10, stiffness: 100 }); + }, []); + + useEffect(() => { + if (isAppReady) { + opacity.value = withDelay( + 600, + withTiming(0, { duration: 700, easing: Easing.inOut(Easing.ease) }, (finished) => { + if (finished) { + runOnJS(onAnimationComplete)(); + } + }) + ); + + scale.value = withDelay( + 600, + withTiming(1.3, { duration: 700, easing: Easing.inOut(Easing.ease) }) + ); + } + }, [isAppReady]); + + const animatedContainerStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }], + })); + + const animatedTextStyle = useAnimatedStyle(() => ({ + opacity: textOpacity.value, + transform: [ + { translateY: textTranslateY.value }, + { scale: logoScale.value } + ], + })); + + return ( + <Animated.View style={[styles.container, animatedContainerStyle]}> + <LinearGradient + colors={['#0D2818', Colors.background]} + style={StyleSheet.absoluteFill} + start={{ x: 0, y: 0 }} + end={{ x: 0, y: 1 }} + /> + <Animated.View style={animatedTextStyle}> + <Text style={styles.logoText}>YABA</Text> + <Text style={styles.subText}>Finanças Pessoais</Text> + </Animated.View> + </Animated.View> + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFill, + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + }, + logoText: { + fontFamily: 'Outfit_700Bold', + fontSize: 72, + color: Colors.white, + letterSpacing: -2, + textAlign: 'center', + }, + subText: { + fontFamily: 'DMSans_500Medium', + fontSize: 16, + color: Colors.primaryLight, + letterSpacing: 2, + textTransform: 'uppercase', + textAlign: 'center', + marginTop: 8, + } +}); diff --git a/src/components/expense-row.tsx b/src/components/expense-row.tsx new file mode 100644 index 0000000..0f58c78 --- /dev/null +++ b/src/components/expense-row.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated'; +import { Colors, Radius, Spacing, Typography } from '@/constants/theme'; +import type { OneTimeExpense, RecurringExpense } from '@/types'; + +const CATEGORY_EMOJI: Record<string, string> = { + moradia: '🏠', + alimentação: '🍽️', + transporte: '🚗', + saúde: '💊', + educação: '📚', + lazer: '🎬', + serviços: '⚙️', + outros: '📦', +}; + +interface ExpenseRowProps { + name: string; + amount: number; + category: string; + subtitle?: string; + index?: number; + onPress?: () => void; + rightElement?: React.ReactNode; + type?: 'expense' | 'income'; +} + +export function ExpenseRow({ + name, + amount, + category, + subtitle, + index = 0, + onPress, + rightElement, + type = 'expense', +}: ExpenseRowProps) { + const emoji = CATEGORY_EMOJI[category] ?? '📦'; + const prefix = type === 'income' ? '+ ' : '- '; + const amountColor = type === 'income' ? Colors.positive : Colors.negative; + + const formattedAmount = amount.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 2, + }); + + return ( + <Animated.View + entering={FadeInDown.delay(index * 60).springify()} + layout={LinearTransition.springify()} + > + <TouchableOpacity + style={styles.row} + onPress={onPress} + activeOpacity={0.7} + disabled={!onPress} + accessibilityRole="button" + accessibilityLabel={`${name}, ${formattedAmount}`} + > + <View style={styles.iconBox}> + <Text style={styles.emoji}>{emoji}</Text> + </View> + + <View style={styles.info}> + <Text style={styles.name} numberOfLines={1}> + {name} + </Text> + {subtitle ? ( + <Text style={styles.subtitle} numberOfLines={1}> + {subtitle} + </Text> + ) : null} + </View> + + <View style={styles.right}> + <Text style={[styles.amount, { color: amountColor }]}> + {prefix}{formattedAmount} + </Text> + {rightElement} + </View> + </TouchableOpacity> + </Animated.View> + ); +} + +interface RecurringExpenseRowProps { + expense: RecurringExpense; + index?: number; + onToggle: () => void; + onPress: () => void; +} + +export function RecurringExpenseRow({ + expense, + index = 0, + onToggle, + onPress, +}: RecurringExpenseRowProps) { + return ( + <ExpenseRow + name={expense.name} + amount={expense.amount} + category={expense.category} + type={expense.type} + subtitle={expense.due_day ? `Vence dia ${expense.due_day}` : undefined} + index={index} + onPress={onPress} + rightElement={ + <TouchableOpacity + style={[ + styles.toggleBadge, + expense.is_active ? styles.toggleActive : styles.toggleInactive, + ]} + onPress={onToggle} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityLabel={expense.is_active ? 'Desativar gasto' : 'Ativar gasto'} + accessibilityRole="switch" + > + <Text style={styles.toggleText}> + {expense.is_active ? 'Ativo' : 'Pausado'} + </Text> + </TouchableOpacity> + } + /> + ); +} + +interface OneTimeExpenseRowProps { + expense: OneTimeExpense; + index?: number; + onPress: () => void; +} + +export function OneTimeExpenseRow({ + expense, + index = 0, + onPress, +}: OneTimeExpenseRowProps) { + const date = new Date(expense.expense_date + 'T12:00:00'); + const formattedDate = date.toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'short', + }); + + return ( + <ExpenseRow + name={expense.name} + amount={expense.amount} + category={expense.category} + subtitle={formattedDate} + index={index} + onPress={onPress} + type={expense.type} + /> + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: Spacing.md, + gap: Spacing.md, + }, + iconBox: { + width: 44, + height: 44, + borderRadius: Radius.md, + backgroundColor: Colors.surfaceElevated, + alignItems: 'center', + justifyContent: 'center', + }, + emoji: { + fontSize: 22, + }, + info: { + flex: 1, + gap: 2, + }, + name: { + ...Typography.bodyLarge, + color: Colors.textPrimary, + }, + subtitle: { + ...Typography.caption, + color: Colors.textSecondary, + }, + right: { + alignItems: 'flex-end', + gap: Spacing.xs, + }, + amount: { + ...Typography.bodyLarge, + fontFamily: 'DMSans_600SemiBold', + }, + toggleBadge: { + paddingHorizontal: Spacing.sm, + paddingVertical: 2, + borderRadius: Radius.full, + }, + toggleActive: { + backgroundColor: `${Colors.positive}22`, + }, + toggleInactive: { + backgroundColor: `${Colors.textMuted}22`, + }, + toggleText: { + ...Typography.caption, + color: Colors.textSecondary, + }, +}); diff --git a/src/components/ui/action-modal.tsx b/src/components/ui/action-modal.tsx new file mode 100644 index 0000000..a1d95be --- /dev/null +++ b/src/components/ui/action-modal.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { BlurView } from 'expo-blur'; +import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown } from 'react-native-reanimated'; +import { Colors, Radius, Spacing, Typography } from '@/constants/theme'; +import { Button } from './button'; + +interface ActionModalProps { + visible: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + onCancel: () => void; + isDestructive?: boolean; +} + +export function ActionModal({ + visible, + title, + message, + confirmText = 'Confirmar', + cancelText = 'Cancelar', + onConfirm, + onCancel, + isDestructive = false, +}: ActionModalProps) { + if (!visible) return null; + + return ( + <Modal visible={visible} transparent animationType="none" onRequestClose={onCancel}> + <Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)} style={styles.overlay}> + <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onCancel} /> + + <Animated.View entering={FadeInDown.duration(300).springify()} exiting={FadeOutDown.duration(200)} style={styles.modalContainer}> + <BlurView intensity={80} tint="dark" style={styles.blurView}> + <View style={styles.content}> + <Text style={styles.title}>{title}</Text> + <Text style={styles.message}>{message}</Text> + + <View style={styles.actions}> + <Button + label={cancelText} + variant="ghost" + onPress={onCancel} + style={styles.actionButton} + /> + <Button + label={confirmText} + variant={isDestructive ? 'danger' : 'primary'} + onPress={onConfirm} + style={styles.actionButton} + /> + </View> + </View> + </BlurView> + </Animated.View> + </Animated.View> + </Modal> + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + }, + backdrop: { + ...StyleSheet.absoluteFill, + }, + modalContainer: { + padding: Spacing.md, + paddingBottom: Spacing.xxl, + }, + blurView: { + borderRadius: Radius.lg, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.08)', + }, + content: { + padding: Spacing.xl, + gap: Spacing.md, + backgroundColor: 'rgba(22, 27, 34, 0.6)', + }, + title: { + ...Typography.h2, + color: Colors.textPrimary, + textAlign: 'center', + }, + message: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + }, + actions: { + flexDirection: 'row', + gap: Spacing.sm, + marginTop: Spacing.md, + }, + actionButton: { + flex: 1, + }, +}); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..3486ff7 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, + type TouchableOpacityProps, + View, +} from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { Colors, Radius, Spacing, Typography } from '@/constants/theme'; + +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); + +interface ButtonProps extends TouchableOpacityProps { + label: string; + variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + loading?: boolean; + fullWidth?: boolean; +} + +export function Button({ + label, + variant = 'primary', + loading = false, + fullWidth = false, + onPress, + disabled, + style, + ...rest +}: ButtonProps) { + const scale = useSharedValue(1); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + function handlePressIn() { + scale.value = withSpring(0.96, { damping: 15, stiffness: 400 }); + } + + function handlePressOut() { + scale.value = withSpring(1, { damping: 15, stiffness: 400 }); + } + + const containerStyle = [ + styles.base, + styles[variant], + fullWidth && styles.fullWidth, + (disabled || loading) && styles.disabled, + style, + ]; + + const textColor = + variant === 'secondary' || variant === 'ghost' + ? Colors.textPrimary + : Colors.white; + + return ( + <AnimatedTouchable + style={[animatedStyle, containerStyle]} + onPress={onPress} + onPressIn={handlePressIn} + onPressOut={handlePressOut} + disabled={disabled || loading} + activeOpacity={1} + {...rest} + > + {loading ? ( + <ActivityIndicator color={textColor} size="small" /> + ) : ( + <Text style={[styles.label, { color: textColor }]}>{label}</Text> + )} + </AnimatedTouchable> + ); +} + +interface IconButtonProps { + onPress: () => void; + children: React.ReactNode; + accessibilityLabel: string; +} + +export function IconButton({ onPress, children, accessibilityLabel }: IconButtonProps) { + return ( + <TouchableOpacity + style={styles.iconButton} + onPress={onPress} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityLabel={accessibilityLabel} + accessibilityRole="button" + > + <View>{children}</View> + </TouchableOpacity> + ); +} + +const styles = StyleSheet.create({ + base: { + height: 52, + borderRadius: Radius.md, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Spacing.lg, + minWidth: 44, // touch target mínimo + }, + primary: { + backgroundColor: Colors.primary, + }, + secondary: { + backgroundColor: Colors.surfaceElevated, + borderWidth: 1, + borderColor: Colors.border, + }, + ghost: { + backgroundColor: Colors.transparent, + }, + danger: { + backgroundColor: Colors.negative, + }, + fullWidth: { + width: '100%', + }, + disabled: { + opacity: 0.5, + }, + label: { + ...Typography.bodyLarge, + fontFamily: 'DMSans_600SemiBold', + }, + iconButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/components/ui/expenses-chart.tsx b/src/components/ui/expenses-chart.tsx new file mode 100644 index 0000000..efd5dee --- /dev/null +++ b/src/components/ui/expenses-chart.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { PieChart } from 'react-native-gifted-charts'; +import { Colors, Radius, Spacing, Typography } from '@/constants/theme'; +import type { DashboardExpenseItem } from '@/types'; +import { Card, SectionLabel } from './primitives'; + +interface ExpensesChartProps { + recurring: DashboardExpenseItem[]; + oneTime: DashboardExpenseItem[]; +} + +const CATEGORY_COLORS: Record<string, string> = { + moradia: '#4C6EF5', + alimentação: '#FA5252', + transporte: '#FAB005', + saúde: '#12B886', + educação: '#BE4BDB', + lazer: '#FF922B', + serviços: '#228BE6', + outros: '#868E96', +}; + +const CATEGORY_LABELS: Record<string, string> = { + moradia: 'Moradia', + alimentação: 'Alimentação', + transporte: 'Transporte', + saúde: 'Saúde', + educação: 'Educação', + lazer: 'Lazer', + serviços: 'Serviços', + outros: 'Outros', +}; + +export function ExpensesChart({ recurring, oneTime }: ExpensesChartProps) { + const categoryTotals: Record<string, number> = {}; + + const allExpenses = [ + ...recurring.filter(e => e.is_active && e.type === 'expense'), + ...oneTime.filter(e => e.type === 'expense') + ]; + + if (allExpenses.length === 0) { + return ( + <Card style={styles.card}> + <SectionLabel text="Análise de Gastos" /> + <View style={styles.emptyContainer}> + <Text style={styles.emptyText}>Não há despesas para analisar neste mês.</Text> + </View> + </Card> + ); + } + + let totalExpenses = 0; + allExpenses.forEach(e => { + if (!categoryTotals[e.category]) { + categoryTotals[e.category] = 0; + } + categoryTotals[e.category] += e.amount; + totalExpenses += e.amount; + }); + + const pieData = Object.keys(categoryTotals) + .map(category => ({ + value: categoryTotals[category], + color: CATEGORY_COLORS[category] || CATEGORY_COLORS.outros, + label: CATEGORY_LABELS[category] || 'Outros', + percentage: ((categoryTotals[category] / totalExpenses) * 100).toFixed(0) + '%', + })) + .sort((a, b) => b.value - a.value); + + return ( + <Card style={styles.card}> + <SectionLabel text="Análise de Gastos" /> + + <View style={styles.chartRow}> + <View style={styles.chartWrapper}> + <PieChart + data={pieData} + donut + radius={70} + innerRadius={50} + innerCircleColor={Colors.surface} + centerLabelComponent={() => { + return ( + <View style={styles.centerLabel}> + <Text style={styles.centerLabelValue}> + {totalExpenses.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })} + </Text> + <Text style={styles.centerLabelText}>Total</Text> + </View> + ); + }} + /> + </View> + + <View style={styles.legendContainer}> + {pieData.map((item, index) => ( + <View key={index} style={styles.legendItem}> + <View style={[styles.legendDot, { backgroundColor: item.color }]} /> + <View style={styles.legendTextWrapper}> + <Text style={styles.legendLabel} numberOfLines={1}>{item.label}</Text> + <Text style={styles.legendValue}> + {item.value.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} ({item.percentage}) + </Text> + </View> + </View> + ))} + </View> + </View> + </Card> + ); +} + +const styles = StyleSheet.create({ + card: { + gap: Spacing.md, + }, + emptyContainer: { + paddingVertical: Spacing.xl, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + ...Typography.body, + color: Colors.textMuted, + }, + chartRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + chartWrapper: { + alignItems: 'center', + justifyContent: 'center', + }, + centerLabel: { + alignItems: 'center', + justifyContent: 'center', + }, + centerLabelValue: { + ...Typography.body, + fontFamily: 'DMSans_700Bold', + color: Colors.textPrimary, + fontSize: 14, + }, + centerLabelText: { + ...Typography.caption, + color: Colors.textSecondary, + fontSize: 10, + }, + legendContainer: { + flex: 1, + marginLeft: Spacing.lg, + gap: Spacing.sm, + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + }, + legendDot: { + width: 10, + height: 10, + borderRadius: 5, + marginRight: Spacing.xs, + }, + legendTextWrapper: { + flex: 1, + }, + legendLabel: { + ...Typography.caption, + color: Colors.textSecondary, + }, + legendValue: { + ...Typography.caption, + color: Colors.textPrimary, + fontFamily: 'DMSans_600SemiBold', + }, +}); diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..25728df --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,98 @@ +import React, { forwardRef, useState } from 'react'; +import { + StyleSheet, + Text, + TextInput, + type TextInputProps, + View, +} from 'react-native'; +import { Colors, Radius, Spacing, Typography } from '@/constants/theme'; + +interface InputProps extends TextInputProps { + label: string; + error?: string; + hint?: string; + prefix?: string; +} + +export const Input = forwardRef<TextInput, InputProps>(function Input( + { label, error, hint, prefix, style, ...rest }, + ref, +) { + const [focused, setFocused] = useState(false); + + return ( + <View style={styles.wrapper}> + <Text style={styles.label}>{label}</Text> + <View + style={[ + styles.inputRow, + focused && styles.inputRowFocused, + !!error && styles.inputRowError, + ]} + > + {prefix ? <Text style={styles.prefix}>{prefix}</Text> : null} + <TextInput + ref={ref} + style={[styles.input, style]} + placeholderTextColor={Colors.textMuted} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + selectionColor={Colors.primaryLight} + {...rest} + /> + </View> + {error ? ( + <Text style={styles.error}>{error}</Text> + ) : hint ? ( + <Text style={styles.hint}>{hint}</Text> + ) : null} + </View> + ); +}); + +const styles = StyleSheet.create({ + wrapper: { + gap: Spacing.xs, + }, + label: { + ...Typography.caption, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 1, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.surfaceElevated, + borderRadius: Radius.md, + borderWidth: 1, + borderColor: Colors.border, + paddingHorizontal: Spacing.md, + height: 52, + }, + inputRowFocused: { + borderColor: Colors.primaryLight, + }, + inputRowError: { + borderColor: Colors.negative, + }, + prefix: { + ...Typography.bodyLarge, + color: Colors.textSecondary, + marginRight: Spacing.sm, + }, + input: { + flex: 1, + ...Typography.bodyLarge, + color: Colors.textPrimary, + }, + error: { + ...Typography.caption, + color: Colors.negative, + }, + hint: { + ...Typography.caption, + color: Colors.textMuted, + }, +}); diff --git a/src/components/ui/options-modal.tsx b/src/components/ui/options-modal.tsx new file mode 100644 index 0000000..8d5d984 --- /dev/null +++ b/src/components/ui/options-modal.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { BlurView } from 'expo-blur'; +import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown } from 'react-native-reanimated'; +import { Feather } from '@expo/vector-icons'; +import { Colors, Radius, Spacing, Typography } from '@/constants/theme'; + +export interface MenuOption { + label: string; + icon?: keyof typeof Feather.glyphMap; + destructive?: boolean; + onPress: () => void; +} + +interface OptionsModalProps { + visible: boolean; + title?: string; + options: MenuOption[]; + onClose: () => void; +} + +export function OptionsModal({ visible, title, options, onClose }: OptionsModalProps) { + if (!visible) return null; + + return ( + <Modal visible={visible} transparent animationType="none" onRequestClose={onClose}> + <Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)} style={styles.overlay}> + <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} /> + + <Animated.View entering={FadeInDown.duration(300).springify()} exiting={FadeOutDown.duration(200)} style={styles.modalContainer}> + <BlurView intensity={80} tint="dark" style={styles.blurView}> + <View style={styles.content}> + {title && <Text style={styles.title}>{title}</Text>} + + <View style={styles.optionsList}> + {options.map((option, index) => ( + <TouchableOpacity + key={index} + style={[styles.optionButton, index < options.length - 1 && styles.optionBorder]} + onPress={() => { + onClose(); + setTimeout(option.onPress, 50); + }} + > + {option.icon && ( + <Feather + name={option.icon} + size={20} + color={option.destructive ? Colors.negative : Colors.textPrimary} + style={styles.optionIcon} + /> + )} + <Text style={[styles.optionText, option.destructive && styles.optionTextDestructive]}> + {option.label} + </Text> + </TouchableOpacity> + ))} + </View> + + <TouchableOpacity style={styles.cancelButton} onPress={onClose}> + <Text style={styles.cancelText}>Cancelar</Text> + </TouchableOpacity> + </View> + </BlurView> + </Animated.View> + </Animated.View> + </Modal> + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + }, + backdrop: { + ...StyleSheet.absoluteFill, + }, + modalContainer: { + padding: Spacing.md, + paddingBottom: Spacing.xxl, + }, + blurView: { + borderRadius: Radius.lg, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.08)', + }, + content: { + padding: Spacing.md, + gap: Spacing.md, + backgroundColor: 'rgba(22, 27, 34, 0.6)', + }, + title: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.xs, + }, + optionsList: { + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: Radius.md, + overflow: 'hidden', + }, + optionButton: { + flexDirection: 'row', + alignItems: 'center', + padding: Spacing.md, + backgroundColor: 'transparent', + }, + optionBorder: { + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.05)', + }, + optionIcon: { + marginRight: Spacing.sm, + }, + optionText: { + ...Typography.bodyLarge, + color: Colors.textPrimary, + }, + optionTextDestructive: { + color: Colors.negative, + }, + cancelButton: { + padding: Spacing.md, + backgroundColor: 'rgba(255,255,255,0.1)', + borderRadius: Radius.md, + alignItems: 'center', + }, + cancelText: { + ...Typography.bodyLarge, + color: Colors.textPrimary, + fontFamily: 'DMSans_600SemiBold', + }, +}); diff --git a/src/components/ui/primitives.tsx b/src/components/ui/primitives.tsx new file mode 100644 index 0000000..d598bd2 --- /dev/null +++ b/src/components/ui/primitives.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Colors, Radius, Shadow, Spacing, Typography } from '@/constants/theme'; + +interface CardProps { + children: React.ReactNode; + style?: object; +} + +export function Card({ children, style }: CardProps) { + return <View style={[styles.card, style]}>{children}</View>; +} + +interface LabelProps { + text: string; + color?: string; +} + +export function SectionLabel({ text, color = Colors.textSecondary }: LabelProps) { + return <Text style={[styles.label, { color }]}>{text}</Text>; +} + +interface CurrencyDisplayProps { + amount: number; + size?: 'display' | 'large' | 'normal'; + showSign?: boolean; +} + +export function CurrencyDisplay({ + amount, + size = 'normal', + showSign = false, +}: CurrencyDisplayProps) { + const isNegative = amount < 0; + const color = isNegative ? Colors.negative : Colors.positive; + const formatted = Math.abs(amount).toLocaleString('pt-BR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + const sign = showSign ? (isNegative ? '- ' : '+ ') : ''; + + const textStyle = + size === 'display' + ? styles.displayAmount + : size === 'large' + ? styles.largeAmount + : styles.normalAmount; + + return ( + <View style={styles.currencyRow}> + <Text style={[styles.currencyPrefix, { color }]}> + {sign}R${' '} + </Text> + <Text style={[textStyle, { color }]}>{formatted}</Text> + </View> + ); +} + +interface DividerProps { + style?: object; +} + +export function Divider({ style }: DividerProps) { + return <View style={[styles.divider, style]} />; +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: Colors.surface, + borderRadius: Radius.lg, + padding: Spacing.lg, + borderWidth: 1, + borderColor: Colors.border, + ...Shadow.card, + }, + label: { + ...Typography.caption, + textTransform: 'uppercase', + letterSpacing: 1.2, + }, + currencyRow: { + flexDirection: 'row', + alignItems: 'flex-end', + }, + currencyPrefix: { + ...Typography.currencyLabel, + paddingBottom: 4, + }, + displayAmount: { + ...Typography.display, + }, + largeAmount: { + ...Typography.h1, + }, + normalAmount: { + ...Typography.bodyLarge, + fontFamily: 'DMSans_600SemiBold', + }, + divider: { + height: 1, + backgroundColor: Colors.border, + marginVertical: Spacing.md, + }, +}); |