aboutsummaryrefslogtreecommitdiff
path: root/src/components/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ui')
-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
6 files changed, 774 insertions, 0 deletions
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,
+ },
+});