aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/animated-splash-screen.tsx103
-rw-r--r--src/components/expense-row.tsx213
-rw-r--r--src/components/ui/action-modal.tsx107
-rw-r--r--src/components/ui/button.tsx141
-rw-r--r--src/components/ui/expenses-chart.tsx187
-rw-r--r--src/components/ui/input.tsx98
-rw-r--r--src/components/ui/options-modal.tsx137
-rw-r--r--src/components/ui/primitives.tsx104
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,
+ },
+});