aboutsummaryrefslogtreecommitdiff
path: root/src/screens/one-time-expenses-screen.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/one-time-expenses-screen.tsx')
-rw-r--r--src/screens/one-time-expenses-screen.tsx485
1 files changed, 485 insertions, 0 deletions
diff --git a/src/screens/one-time-expenses-screen.tsx b/src/screens/one-time-expenses-screen.tsx
new file mode 100644
index 0000000..c84a39f
--- /dev/null
+++ b/src/screens/one-time-expenses-screen.tsx
@@ -0,0 +1,485 @@
+import React, { useCallback, useState } from 'react';
+import {
+ Alert,
+ 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 { OneTimeExpenseRow } from '@/components/expense-row';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+import type { Category, OneTimeExpense } from '@/types';
+import {
+ createOneTimeExpense,
+ updateOneTimeExpense,
+ deleteOneTimeExpense,
+ getOneTimeExpenses,
+} 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";
+
+const CATEGORIES: { value: Category; emoji: string; label: string }[] = [
+ { value: 'alimentação', emoji: '🍽️', label: 'Alimentação' },
+ { value: 'lazer', emoji: '🎬', label: 'Lazer' },
+ { value: 'transporte', emoji: '🚗', label: 'Transporte' },
+ { value: 'saúde', emoji: '💊', label: 'Saúde' },
+ { value: 'moradia', emoji: '🏠', label: 'Moradia' },
+ { value: 'educação', emoji: '📚', label: 'Educação' },
+ { value: 'serviços', emoji: '⚙️', label: 'Serviços' },
+ { value: 'outros', emoji: '📦', label: 'Outros' },
+];
+
+export default function OneTimeExpensesScreen() {
+ const insets = useSafeAreaInsets();
+ const { user } = useAuth();
+ const [expenses, setExpenses] = useState<OneTimeExpense[]>([]);
+ const [showForm, setShowForm] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const [name, setName] = useState('');
+ const [amount, setAmount] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState<Category>('outros');
+ const [expenseType, setExpenseType] = useState<'expense' | 'income'>('expense');
+ const [notes, setNotes] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const [expenseToDelete, setExpenseToDelete] = useState<number | null>(null);
+ const [selectedExpense, setSelectedExpense] = useState<OneTimeExpense | 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 getOneTimeExpenses(user.id);
+ setExpenses(data);
+ } catch (err) {
+ Alert.alert('Erro', 'Não foi possível carregar os gastos do mês.');
+ } finally {
+ setLoading(false);
+ }
+ }, [user]);
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchExpenses();
+ }, [fetchExpenses])
+ );
+
+ const monthTotal = expenses.reduce((sum, e) => {
+ return sum + (e.type === 'income' ? -e.amount : e.amount);
+ }, 0);
+ const isTotalIncome = monthTotal < 0;
+ const absTotal = Math.abs(monthTotal);
+ const formattedTotal = absTotal.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ });
+
+ 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 updateOneTimeExpense(user.id, editingExpenseId, {
+ name: name.trim(),
+ amount: amountNum,
+ category: selectedCategory,
+ type: expenseType,
+ notes: notes.trim() || null,
+ });
+ setExpenses((prev) => prev.map((e) => e.id === editingExpenseId ? updated : e));
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ } else {
+ const today = new Date().toISOString().split('T')[0];
+ const newExpense = await createOneTimeExpense(user.id, {
+ name: name.trim(),
+ amount: amountNum,
+ category: selectedCategory,
+ type: expenseType,
+ expense_date: today,
+ notes: notes.trim() || null,
+ });
+ setExpenses((prev) => [...prev, newExpense]);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }
+
+ setName('');
+ setAmount('');
+ setNotes('');
+ setExpenseType('expense');
+ 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: OneTimeExpense) {
+ setName(expense.name);
+ setAmount(expense.amount.toString());
+ setSelectedCategory(expense.category);
+ setExpenseType(expense.type);
+ setNotes(expense.notes || '');
+ setEditingExpenseId(expense.id);
+ setSelectedExpense(null);
+ setShowForm(true);
+ }
+
+ async function confirmDelete() {
+ if (!user || expenseToDelete === null) return;
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ try {
+ await deleteOneTimeExpense(user.id, expenseToDelete);
+ setExpenses((prev) => prev.filter((e) => e.id !== expenseToDelete));
+ } catch (err) {
+ Alert.alert('Erro', 'Não foi possível remover o lançamento.');
+ } finally {
+ setExpenseToDelete(null);
+ }
+ }
+
+ return (
+ <View style={[styles.container, { paddingTop: insets.top }]}>
+ {isFocused && (
+ <ScrollView
+ contentContainerStyle={[
+ styles.scroll,
+ { paddingBottom: insets.bottom + Spacing.xxl },
+ ]}
+ keyboardShouldPersistTaps="handled"
+ showsVerticalScrollIndicator={false}
+ >
+ <Animated.View entering={FadeInDown.duration(400)} style={styles.header}>
+ <View>
+ <Text style={styles.title}>Lançamentos</Text>
+ <Text style={styles.subtitle}>
+ Saldo líquido:{' '}
+ <Text
+ style={[
+ styles.totalHighlight,
+ isTotalIncome ? { color: Colors.positive } : { color: Colors.negative }
+ ]}
+ >
+ {isTotalIncome ? '+ ' : (monthTotal > 0 ? '- ' : '')}{formattedTotal}
+ </Text>
+ </Text>
+ </View>
+ <TouchableOpacity
+ id="onetime-add-button"
+ style={[styles.addButton, showForm && styles.addButtonActive]}
+ onPress={() => {
+ if (showForm) {
+ setEditingExpenseId(null);
+ setName('');
+ setAmount('');
+ setNotes('');
+ }
+ setShowForm((v) => !v);
+ }}
+ accessibilityLabel="Adicionar lançamento"
+ 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"} />
+
+ <View style={styles.typeSelector}>
+ <TouchableOpacity
+ style={[styles.typeButton, expenseType === 'expense' && styles.typeButtonExpenseActive]}
+ onPress={() => setExpenseType('expense')}
+ >
+ <Text style={[styles.typeButtonText, expenseType === 'expense' && { color: Colors.negative }]}>
+ Despesa
+ </Text>
+ </TouchableOpacity>
+ <TouchableOpacity
+ style={[styles.typeButton, expenseType === 'income' && styles.typeButtonIncomeActive]}
+ onPress={() => setExpenseType('income')}
+ >
+ <Text style={[styles.typeButtonText, expenseType === 'income' && { color: Colors.positive }]}>
+ Receita
+ </Text>
+ </TouchableOpacity>
+ </View>
+
+ <Input
+ id="one-time-name-input"
+ label="Descrição"
+ value={name}
+ onChangeText={setName}
+ placeholder="Ex: iFood, Cinema, Uber..."
+ />
+
+ <Input
+ id="one-time-amount-input"
+ label="Valor"
+ prefix="R$"
+ value={amount}
+ onChangeText={setAmount}
+ keyboardType="decimal-pad"
+ placeholder="0,00"
+ />
+
+ <View>
+ <Text style={styles.catLabel}>Categoria</Text>
+ <View style={styles.categoryGrid}>
+ {CATEGORIES.map((cat) => (
+ <TouchableOpacity
+ key={cat.value}
+ style={[
+ styles.catChip,
+ selectedCategory === cat.value && styles.catChipSelected,
+ ]}
+ onPress={() => setSelectedCategory(cat.value)}
+ accessibilityLabel={cat.label}
+ accessibilityRole="radio"
+ >
+ <Text style={styles.catEmoji}>{cat.emoji}</Text>
+ <Text
+ style={[
+ styles.catText,
+ selectedCategory === cat.value && styles.catTextSelected,
+ ]}
+ >
+ {cat.label}
+ </Text>
+ </TouchableOpacity>
+ ))}
+ </View>
+ </View>
+
+ <Input
+ id="one-time-notes-input"
+ label="Observações (opcional)"
+ value={notes}
+ onChangeText={setNotes}
+ placeholder="Ex: almoço de aniversário"
+ multiline
+ />
+
+ <Button
+ id="one-time-save-button"
+ label={expenseType === 'income' ? 'Adicionar Receita' : 'Adicionar Gasto'}
+ fullWidth
+ onPress={handleAdd}
+ loading={isSubmitting}
+ disabled={isSubmitting}
+ style={expenseType === 'income' ? { backgroundColor: Colors.positive } : undefined}
+ />
+ </Card>
+ </Animated.View>
+ )}
+
+ <Animated.View layout={LinearTransition.springify()}>
+ <Card style={styles.listCard}>
+ <SectionLabel text={`${expenses.length} lançamentos este mês`} />
+ {expenses.length === 0 ? (
+ <Text style={styles.emptyText}>
+ Nenhum lançamento registrado este mês.{'\n'}Toque em + para adicionar.
+ </Text>
+ ) : (
+ expenses.map((expense, i) => (
+ <React.Fragment key={expense.id}>
+ {i > 0 && <Divider style={styles.rowDivider} />}
+ <OneTimeExpenseRow
+ expense={expense}
+ index={i}
+ onPress={() => setSelectedExpense(expense)}
+ />
+ </React.Fragment>
+ ))
+ )}
+ </Card>
+ </Animated.View>
+ </ScrollView>
+ )}
+
+ <ActionModal
+ visible={expenseToDelete !== null}
+ title="Remover lançamento"
+ message="Deseja realmente remover este lançamento? Esta ação não pode ser desfeita."
+ 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: '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,
+ },
+ typeSelector: {
+ flexDirection: 'row',
+ gap: Spacing.sm,
+ },
+ typeButton: {
+ flex: 1,
+ paddingVertical: Spacing.sm,
+ alignItems: 'center',
+ borderRadius: Radius.md,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ backgroundColor: Colors.surfaceElevated,
+ },
+ typeButtonExpenseActive: {
+ borderColor: Colors.negative,
+ backgroundColor: `${Colors.negative}1A`,
+ },
+ typeButtonIncomeActive: {
+ borderColor: Colors.positive,
+ backgroundColor: `${Colors.positive}1A`,
+ },
+ typeButtonText: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+ listCard: {
+ gap: Spacing.xs,
+ },
+ rowDivider: {
+ marginVertical: 0,
+ },
+ emptyText: {
+ ...Typography.body,
+ color: Colors.textMuted,
+ textAlign: 'center',
+ paddingVertical: Spacing.xl,
+ lineHeight: 24,
+ },
+ catLabel: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ marginBottom: Spacing.sm,
+ },
+ categoryGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: Spacing.sm,
+ },
+ catChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.xs,
+ paddingVertical: Spacing.sm,
+ paddingHorizontal: Spacing.md,
+ borderRadius: Radius.full,
+ backgroundColor: Colors.surfaceElevated,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ catChipSelected: {
+ borderColor: Colors.primaryLight,
+ backgroundColor: `${Colors.primary}33`,
+ },
+ catEmoji: {
+ fontSize: 14,
+ },
+ catText: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ },
+ catTextSelected: {
+ color: Colors.primaryLight,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+});