aboutsummaryrefslogtreecommitdiff
path: root/src/screens/recurring-expenses-screen.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/recurring-expenses-screen.tsx')
-rw-r--r--src/screens/recurring-expenses-screen.tsx442
1 files changed, 442 insertions, 0 deletions
diff --git a/src/screens/recurring-expenses-screen.tsx b/src/screens/recurring-expenses-screen.tsx
new file mode 100644
index 0000000..ea6f165
--- /dev/null
+++ b/src/screens/recurring-expenses-screen.tsx
@@ -0,0 +1,442 @@
+import React, { useCallback, useState } from 'react';
+import {
+ Alert,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import * as Haptics from 'expo-haptics';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Card, Divider, SectionLabel } from '@/components/ui/primitives';
+import { RecurringExpenseRow } from '@/components/expense-row';
+import { Colors, Spacing, Typography } from '@/constants/theme';
+import type { RecurringExpense } from '@/types';
+import {
+ createRecurringExpense,
+ deleteRecurringExpense,
+ getRecurringExpenses,
+ toggleRecurringExpense,
+ updateRecurringExpense,
+} from '@/services/api';
+import { useAuth } from '@/contexts/AuthContext';
+import { ActionModal } from '@/components/ui/action-modal';
+import { OptionsModal } from '@/components/ui/options-modal';
+import { useFocusEffect } from 'expo-router';
+import { useIsFocused } from "expo-router/react-navigation";
+
+export default function RecurringExpensesScreen() {
+ const insets = useSafeAreaInsets();
+ const { user } = useAuth();
+ const [expenses, setExpenses] = useState<RecurringExpense[]>([]);
+ const [showForm, setShowForm] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const [name, setName] = useState('');
+ const [amount, setAmount] = useState('');
+ const [expenseType, setExpenseType] = useState<'expense' | 'income'>('expense');
+ const [dueDay, setDueDay] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const [expenseToDelete, setExpenseToDelete] = useState<number | null>(null);
+ const [selectedExpense, setSelectedExpense] = useState<RecurringExpense | null>(null);
+ const [editingExpenseId, setEditingExpenseId] = useState<number | null>(null);
+
+ const isFocused = useIsFocused();
+
+ const fetchExpenses = useCallback(async () => {
+ if (!user) return;
+ try {
+ setLoading(true);
+ const data = await getRecurringExpenses(user.id);
+ setExpenses(data);
+ } catch (err) {
+ Alert.alert('Erro', 'Não foi possível carregar os gastos.');
+ } finally {
+ setLoading(false);
+ }
+ }, [user]);
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchExpenses();
+ }, [fetchExpenses])
+ );
+
+ const activeTotal = expenses
+ .filter((e) => e.is_active)
+ .reduce((sum, e) => {
+ return sum + (e.type === 'income' ? -e.amount : e.amount);
+ }, 0);
+
+ const isTotalIncome = activeTotal < 0;
+ const absTotal = Math.abs(activeTotal);
+ const formattedTotal = absTotal.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ });
+
+ async function handleToggle(id: number) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+
+ setExpenses((prev) =>
+ prev.map((e) => (e.id === id ? { ...e, is_active: !e.is_active } : e)),
+ );
+ try {
+ if (!user) return;
+ await toggleRecurringExpense(user.id, id);
+ } catch (err) {
+ setExpenses((prev) =>
+ prev.map((e) => (e.id === id ? { ...e, is_active: !e.is_active } : e)),
+ );
+ Alert.alert('Erro', 'Não foi possível alterar o status do gasto.');
+ }
+ }
+
+ async function handleAdd() {
+ const amountNum = parseFloat(amount.replace(',', '.'));
+ if (!name.trim() || isNaN(amountNum) || amountNum <= 0) {
+ Alert.alert('Dados inválidos', 'Preencha o nome e um valor válido.');
+ return;
+ }
+
+ try {
+ if (!user) return;
+ setIsSubmitting(true);
+
+ if (editingExpenseId) {
+ const updated = await updateRecurringExpense(user.id, editingExpenseId, {
+ name: name.trim(),
+ amount: amountNum,
+ category: 'outros',
+ type: expenseType,
+ due_day: dueDay ? parseInt(dueDay, 10) : null,
+ });
+ setExpenses((prev) => prev.map((e) => e.id === editingExpenseId ? updated : e));
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ } else {
+ const newExpense = await createRecurringExpense(user.id, {
+ name: name.trim(),
+ amount: amountNum,
+ category: 'outros',
+ type: expenseType,
+ due_day: dueDay ? parseInt(dueDay, 10) : null,
+ is_active: true,
+ starts_at: new Date().toISOString().split('T')[0],
+ ends_at: null,
+ });
+ setExpenses((prev) => [...prev, newExpense]);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }
+
+ setName('');
+ setAmount('');
+ setExpenseType('expense');
+ setDueDay('');
+ setShowForm(false);
+ setEditingExpenseId(null);
+ } catch (err) {
+ Alert.alert('Erro', 'Não foi possível salvar o lançamento.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ function handleDelete(id: number) {
+ setExpenseToDelete(id);
+ setSelectedExpense(null);
+ }
+
+ function handleEdit(expense: RecurringExpense) {
+ setName(expense.name);
+ setAmount(expense.amount.toString());
+ setExpenseType(expense.type);
+ setDueDay(expense.due_day ? expense.due_day.toString() : '');
+ setEditingExpenseId(expense.id);
+ setSelectedExpense(null);
+ setShowForm(true);
+ }
+
+ async function confirmDelete() {
+ if (!user || expenseToDelete === null) return;
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ try {
+ await deleteRecurringExpense(user.id, expenseToDelete);
+ setExpenses((prev) => prev.filter((e) => e.id !== expenseToDelete));
+ } catch (err) {
+ Alert.alert('Erro', 'Não foi possível remover o gasto.');
+ } finally {
+ setExpenseToDelete(null);
+ }
+ }
+
+ return (
+ <View style={[styles.container, { paddingTop: insets.top }]}>
+ {isFocused && (
+ <ScrollView
+ contentContainerStyle={[
+ styles.scroll,
+ { paddingBottom: insets.bottom + 80 },
+ ]}
+ keyboardShouldPersistTaps="handled"
+ showsVerticalScrollIndicator={false}
+ >
+ <Animated.View entering={FadeInDown.duration(400)} style={styles.header}>
+ <View>
+ <Text style={styles.title}>Lançamentos Fixos</Text>
+ <Text style={styles.subtitle}>
+ Saldo líquido:{' '}
+ <Text
+ style={[
+ styles.totalHighlight,
+ isTotalIncome ? { color: Colors.positive } : { color: Colors.negative }
+ ]}
+ >
+ {isTotalIncome ? '+ ' : (activeTotal > 0 ? '- ' : '')}{formattedTotal}
+ </Text>
+ </Text>
+ </View>
+ <TouchableOpacity
+ id="recurring-add-button"
+ style={[styles.addButton, showForm && styles.addButtonActive]}
+ onPress={() => {
+ if (showForm) {
+ setEditingExpenseId(null);
+ setName('');
+ setAmount('');
+ setDueDay('');
+ }
+ setShowForm((v) => !v);
+ }}
+ accessibilityLabel="Adicionar lançamento fixo"
+ accessibilityRole="button"
+ >
+ <Text style={styles.addButtonText}>{showForm ? '✕' : '+'}</Text>
+ </TouchableOpacity>
+ </Animated.View>
+
+ {showForm && (
+ <Animated.View entering={FadeInDown.duration(300)}>
+ <Card style={styles.formCard}>
+ <SectionLabel text={editingExpenseId ? "Editar Lançamento" : "Novo Lançamento Fixo"} />
+ <Input
+ id="recurring-name-input"
+ label="Nome"
+ value={name}
+ onChangeText={setName}
+ placeholder="Ex: Academia, Netflix..."
+ />
+ <Input
+ id="recurring-amount-input"
+ label="Valor Mensal"
+ prefix="R$"
+ value={amount}
+ onChangeText={setAmount}
+ keyboardType="decimal-pad"
+ placeholder="0,00"
+ />
+
+ <View style={styles.typeToggle}>
+ <TouchableOpacity
+ style={[styles.typeButton, expenseType === 'expense' && styles.typeButtonExpense]}
+ onPress={() => setExpenseType('expense')}
+ >
+ <Text style={[styles.typeButtonText, expenseType === 'expense' && styles.typeButtonTextActive]}>
+ Despesa
+ </Text>
+ </TouchableOpacity>
+ <TouchableOpacity
+ style={[styles.typeButton, expenseType === 'income' && styles.typeButtonIncome]}
+ onPress={() => setExpenseType('income')}
+ >
+ <Text style={[styles.typeButtonText, expenseType === 'income' && styles.typeButtonTextActive]}>
+ Receita
+ </Text>
+ </TouchableOpacity>
+ </View>
+
+ <Input
+ id="recurring-due-day-input"
+ label="Dia de Vencimento (opcional)"
+ value={dueDay}
+ onChangeText={setDueDay}
+ keyboardType="number-pad"
+ placeholder="Ex: 5"
+ hint="Dia do mês em que costuma vencer"
+ />
+ <Button
+ id="recurring-save-button"
+ label={editingExpenseId ? "Salvar Alterações" : "Adicionar Lançamento Fixo"}
+ fullWidth
+ onPress={handleAdd}
+ loading={isSubmitting}
+ disabled={isSubmitting}
+ />
+ </Card>
+ </Animated.View>
+ )}
+
+ <Animated.View layout={LinearTransition.springify()}>
+ <Card style={styles.listCard}>
+ <SectionLabel text={`${expenses.length} lançamentos cadastrados`} />
+ {expenses.length === 0 ? (
+ <Text style={styles.emptyText}>
+ Nenhum lançamento fixo cadastrado.{'\n'}Toque em + para adicionar.
+ </Text>
+ ) : (
+ expenses.map((expense, i) => (
+ <React.Fragment key={expense.id}>
+ {i > 0 && <Divider style={styles.rowDivider} />}
+ <RecurringExpenseRow
+ expense={expense}
+ index={i}
+ onToggle={() => handleToggle(expense.id)}
+ onPress={() => setSelectedExpense(expense)}
+ />
+ </React.Fragment>
+ ))
+ )}
+ </Card>
+ </Animated.View>
+ </ScrollView>
+ )}
+
+ <ActionModal
+ visible={expenseToDelete !== null}
+ title="Remover lançamento fixo"
+ message="Deseja realmente remover este lançamento? Ele deixará de afetar seu saldo mensal."
+ confirmText="Remover"
+ isDestructive={true}
+ onCancel={() => setExpenseToDelete(null)}
+ onConfirm={confirmDelete}
+ />
+
+ <OptionsModal
+ visible={selectedExpense !== null}
+ title={selectedExpense?.name}
+ onClose={() => setSelectedExpense(null)}
+ options={[
+ {
+ label: 'Editar',
+ icon: 'edit-2',
+ onPress: () => selectedExpense && handleEdit(selectedExpense),
+ },
+ {
+ label: selectedExpense?.is_active ? 'Pausar' : 'Ativar',
+ icon: selectedExpense?.is_active ? 'pause-circle' : 'play-circle',
+ onPress: () => {
+ if (selectedExpense) {
+ handleToggle(selectedExpense.id);
+ setSelectedExpense(null);
+ }
+ },
+ },
+ {
+ label: 'Excluir',
+ icon: 'trash-2',
+ destructive: true,
+ onPress: () => selectedExpense && handleDelete(selectedExpense.id),
+ }
+ ]}
+ />
+ </View>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.background,
+ },
+ scroll: {
+ padding: Spacing.md,
+ gap: Spacing.md,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ },
+ title: {
+ ...Typography.h1,
+ color: Colors.textPrimary,
+ },
+ subtitle: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ marginTop: 4,
+ },
+ totalHighlight: {
+ color: Colors.negative,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+ addButton: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ backgroundColor: Colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ addButtonActive: {
+ backgroundColor: Colors.surfaceElevated,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ addButtonText: {
+ ...Typography.h2,
+ color: Colors.white,
+ },
+ formCard: {
+ gap: Spacing.md,
+ },
+ listCard: {
+ gap: Spacing.xs,
+ },
+ rowDivider: {
+ marginVertical: 0,
+ },
+ emptyText: {
+ ...Typography.body,
+ color: Colors.textMuted,
+ textAlign: 'center',
+ paddingVertical: Spacing.xl,
+ lineHeight: 24,
+ },
+ typeToggle: {
+ flexDirection: 'row',
+ gap: Spacing.sm,
+ marginBottom: Spacing.sm,
+ },
+ typeButton: {
+ flex: 1,
+ paddingVertical: Spacing.sm,
+ alignItems: 'center',
+ borderRadius: 12,
+ backgroundColor: Colors.surfaceElevated,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ typeButtonExpense: {
+ backgroundColor: `${Colors.negative}15`,
+ borderColor: `${Colors.negative}40`,
+ },
+ typeButtonIncome: {
+ backgroundColor: `${Colors.positive}15`,
+ borderColor: `${Colors.positive}40`,
+ },
+ typeButtonText: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ fontFamily: 'DMSans_500Medium',
+ },
+ typeButtonTextActive: {
+ color: Colors.textPrimary,
+ fontFamily: 'DMSans_700Bold',
+ },
+});