diff options
Diffstat (limited to 'src/screens')
| -rw-r--r-- | src/screens/dashboard-screen.tsx | 440 | ||||
| -rw-r--r-- | src/screens/one-time-expenses-screen.tsx | 485 | ||||
| -rw-r--r-- | src/screens/recurring-expenses-screen.tsx | 442 | ||||
| -rw-r--r-- | src/screens/salary-screen.tsx | 232 |
4 files changed, 1599 insertions, 0 deletions
diff --git a/src/screens/dashboard-screen.tsx b/src/screens/dashboard-screen.tsx new file mode 100644 index 0000000..c4a533c --- /dev/null +++ b/src/screens/dashboard-screen.tsx @@ -0,0 +1,440 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + FadeInDown, + FadeInUp, + LinearTransition, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Card, CurrencyDisplay, Divider, SectionLabel } from '@/components/ui/primitives'; +import { ExpensesChart } from '@/components/ui/expenses-chart'; +import { Colors, Spacing, Typography } from '@/constants/theme'; +import { getDashboard } from '@/services/api'; +import type { DashboardData } from '@/types'; +import { useAuth } from '@/contexts/AuthContext'; +import { useRouter, useFocusEffect } from 'expo-router'; +import { useIsFocused } from "expo-router/react-navigation"; + +const MONTH_NAMES = [ + '', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', + 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro', +]; + +export default function DashboardScreen() { + const insets = useSafeAreaInsets(); + const [refreshing, setRefreshing] = useState(false); + const [data, setData] = useState<DashboardData | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const { user, logout } = useAuth(); + const router = useRouter(); + const isFocused = useIsFocused(); + + const [targetMonth, setTargetMonth] = useState(() => new Date().getMonth() + 1); + const [targetYear, setTargetYear] = useState(() => new Date().getFullYear()); + + const fetchDashboard = useCallback(async () => { + if (!user) return; + try { + setError(null); + const res = await getDashboard(user.id, targetMonth, targetYear); + setData(res); + } catch (err) { + setError('Não foi possível carregar os dados.'); + } finally { + setLoading(false); + } + }, [targetMonth, targetYear, user]); + + useFocusEffect( + useCallback(() => { + fetchDashboard(); + }, [fetchDashboard]) + ); + + async function handleRefresh() { + setRefreshing(true); + await fetchDashboard(); + setRefreshing(false); + } + + if (loading) { + return ( + <View style={[styles.container, styles.centered]}> + <ActivityIndicator size="large" color={Colors.primary} /> + </View> + ); + } + + if (error || !data) { + return ( + <View style={[styles.container, styles.centered]}> + <Text style={styles.errorText}>{error}</Text> + </View> + ); + } + + const isPositive = data.balance.available >= 0; + const monthName = MONTH_NAMES[data.period.month]; + + return ( + <View style={[styles.container, { paddingTop: insets.top }]}> + <LinearGradient + colors={['#0D2818', Colors.background]} + style={styles.headerGradient} + start={{ x: 0, y: 0 }} + end={{ x: 0, y: 1 }} + /> + + {isFocused && ( + <ScrollView + contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + 80 }]} + showsVerticalScrollIndicator={false} + refreshControl={ + <RefreshControl + refreshing={refreshing} + onRefresh={handleRefresh} + tintColor={Colors.primaryLight} + /> + } + > + <Animated.View entering={FadeInUp.duration(500)} style={styles.headerTop}> + <View style={styles.greeting}> + <Text style={styles.greetingText}>Olá, {data.user.name} 👋</Text> + + <View style={styles.monthSelector}> + <TouchableOpacity + onPress={() => { + let m = targetMonth - 1; + let y = targetYear; + if (m < 1) { m = 12; y -= 1; } + setTargetMonth(m); + setTargetYear(y); + setLoading(true); + }} + style={styles.monthButton} + > + <Feather name="chevron-left" size={20} color={Colors.textSecondary} /> + </TouchableOpacity> + + <Text style={styles.periodText}> + {monthName} {data.period.year} + </Text> + + <TouchableOpacity + onPress={() => { + let m = targetMonth + 1; + let y = targetYear; + if (m > 12) { m = 1; y += 1; } + setTargetMonth(m); + setTargetYear(y); + setLoading(true); + }} + style={styles.monthButton} + > + <Feather name="chevron-right" size={20} color={Colors.textSecondary} /> + </TouchableOpacity> + </View> + </View> + + <TouchableOpacity + style={styles.logoutBtn} + onPress={async () => { + await logout(); + router.replace('/login'); + }} + > + <Feather name="log-out" size={20} color={Colors.textSecondary} /> + </TouchableOpacity> + </Animated.View> + + <Animated.View entering={FadeInUp.delay(100).duration(500)} layout={LinearTransition.springify()}> + <Card style={[styles.heroCard, isPositive ? styles.heroPositive : styles.heroNegative]}> + <SectionLabel + text="Saldo Disponível" + color={isPositive ? Colors.positive : Colors.negative} + /> + <View style={styles.heroBalance}> + <CurrencyDisplay amount={data.balance.available} size="display" /> + </View> + + <View style={styles.ratioSection}> + <View style={styles.ratioBar}> + <View + style={[ + styles.ratioFill, + { + width: `${Math.min(data.balance.expense_ratio, 100)}%` as `${number}%`, + backgroundColor: + data.balance.expense_ratio > 80 ? Colors.negative : Colors.primary, + }, + ]} + /> + </View> + <Text style={styles.ratioText}> + {data.balance.expense_ratio.toFixed(1)}% do salário comprometido + </Text> + </View> + </Card> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(200).duration(500)} layout={LinearTransition.springify()}> + <Card style={styles.card}> + <SectionLabel text="Salário" /> + <View style={styles.salaryRow}> + <View style={styles.salaryItem}> + <Text style={styles.salaryLabel}>Bruto</Text> + <Text style={styles.salaryValue}> + {data.salary.gross.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + <View style={styles.salaryArrow}> + <Text style={styles.salaryArrowText}>→</Text> + </View> + <View style={styles.salaryItem}> + <Text style={styles.salaryLabel}>Líquido</Text> + <Text style={[styles.salaryValue, { color: Colors.positive }]}> + {data.salary.effective_net.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + </View> + <Divider /> + <View style={styles.taxRow}> + <Text style={styles.taxLabel}>INSS</Text> + <Text style={styles.taxValue}> + -{data.salary.inss_discount.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + </Text> + </View> + <View style={styles.taxRow}> + <Text style={styles.taxLabel}>IRRF</Text> + <Text style={styles.taxValue}> + -{data.salary.irrf_discount.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + </Text> + </View> + </Card> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(250).duration(500)} layout={LinearTransition.springify()}> + <ExpensesChart + recurring={data.expenses.recurring_list} + oneTime={data.expenses.one_time_list} + /> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(300).duration(500)} layout={LinearTransition.springify()}> + <Card style={styles.card}> + <View style={[styles.sectionHeader, { marginBottom: 0 }]}> + <SectionLabel text="Lançamentos Fixos" /> + <Text + style={[ + styles.totalBadge, + data.expenses.recurring_total < 0 ? { color: Colors.positive } : { color: Colors.negative } + ]} + > + {data.expenses.recurring_total < 0 ? '+ ' : (data.expenses.recurring_total > 0 ? '- ' : '')} + {Math.abs(data.expenses.recurring_total).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + </Card> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(400).duration(500)} layout={LinearTransition.springify()}> + <Card style={styles.card}> + <View style={[styles.sectionHeader, { marginBottom: 0 }]}> + <SectionLabel text="Lançamentos do Mês" /> + <Text + style={[ + styles.totalBadge, + data.expenses.one_time_total < 0 ? { color: Colors.positive } : { color: Colors.negative } + ]} + > + {data.expenses.one_time_total < 0 ? '+ ' : (data.expenses.one_time_total > 0 ? '- ' : '')} + {Math.abs(data.expenses.one_time_total).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + </Card> + </Animated.View> + </ScrollView> + )} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background, + }, + centered: { + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + ...Typography.body, + color: Colors.negative, + }, + headerGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 200, + }, + scroll: { + padding: Spacing.md, + gap: Spacing.md, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: Spacing.sm, + }, + greeting: { + flex: 1, + }, + logoutBtn: { + padding: 8, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + }, + greetingText: { + ...Typography.h2, + color: Colors.textPrimary, + }, + periodText: { + ...Typography.caption, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 1.5, + }, + monthSelector: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 6, + gap: Spacing.xs, + }, + monthButton: { + padding: 4, + }, + card: { + gap: Spacing.sm, + }, + heroCard: { + gap: Spacing.md, + borderWidth: 1, + }, + heroPositive: { + borderColor: `${Colors.positive}40`, + }, + heroNegative: { + borderColor: `${Colors.negative}40`, + }, + heroBalance: { + marginTop: Spacing.xs, + }, + ratioSection: { + gap: Spacing.xs, + }, + ratioBar: { + height: 4, + backgroundColor: Colors.border, + borderRadius: 2, + overflow: 'hidden', + }, + ratioFill: { + height: '100%', + borderRadius: 2, + }, + ratioText: { + ...Typography.caption, + color: Colors.textMuted, + }, + salaryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: Spacing.sm, + }, + salaryItem: { + flex: 1, + gap: 4, + }, + salaryLabel: { + ...Typography.caption, + color: Colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 1, + }, + salaryValue: { + ...Typography.h2, + color: Colors.textPrimary, + }, + salaryArrow: { + paddingHorizontal: Spacing.md, + }, + salaryArrowText: { + ...Typography.h2, + color: Colors.textMuted, + }, + taxRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + taxLabel: { + ...Typography.body, + color: Colors.textSecondary, + }, + taxValue: { + ...Typography.body, + color: Colors.negative, + fontFamily: 'DMSans_600SemiBold', + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.xs, + }, + totalBadge: { + ...Typography.body, + color: Colors.negative, + fontFamily: 'DMSans_600SemiBold', + }, + rowDivider: { + marginVertical: 0, + }, +}); 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', + }, +}); 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', + }, +}); diff --git a/src/screens/salary-screen.tsx b/src/screens/salary-screen.tsx new file mode 100644 index 0000000..6847c4b --- /dev/null +++ b/src/screens/salary-screen.tsx @@ -0,0 +1,232 @@ +import { useState } from 'react'; +import { + Alert, + ScrollView, + StyleSheet, + Text, + 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 { useSalaryCalculator } from '@/hooks/use-app-hooks'; +import { Colors, Spacing, Typography } from '@/constants/theme'; +import { formatCurrency } from '@/utils/tax-calculator'; +import { updateUserSalary } from '@/services/api'; +import { useAuth } from '@/contexts/AuthContext'; +import { useIsFocused } from "expo-router/react-navigation"; + +export default function SalaryScreen() { + const insets = useSafeAreaInsets(); + const { user, updateUser } = useAuth(); + const isFocused = useIsFocused(); + + const [grossStr, setGrossStr] = useState(user?.gross_salary ? String(user.gross_salary) : ''); + const [netOverrideStr, setNetOverrideStr] = useState( + user?.net_salary_override ? String(user.net_salary_override) : '' + ); + const [isOverriding, setIsOverriding] = useState(!!user?.net_salary_override); + + const result = useSalaryCalculator(grossStr); + + const effectiveNet = isOverriding + ? parseFloat(netOverrideStr.replace(',', '.')) || 0 + : (result?.salarioLiquidoCalculado ?? 0); + + const [isSaving, setIsSaving] = useState(false); + const [saved, setSaved] = useState(false); + + async function handleSave() { + if (!result || !user) return; + try { + setIsSaving(true); + setSaved(false); + const updatedUser = await updateUserSalary(user.id, { + gross_salary: result.salarioBruto, + net_salary_override: isOverriding ? effectiveNet : null, + }); + await updateUser(updatedUser); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + } catch (err) { + Alert.alert('Erro', 'Não foi possível salvar o salário.'); + } finally { + setIsSaving(false); + } + } + + function handleToggleOverride() { + if (isOverriding) { + setNetOverrideStr(''); + setIsOverriding(false); + } else { + setNetOverrideStr(result?.salarioLiquidoCalculado.toFixed(2) ?? ''); + setIsOverriding(true); + } + } + + 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)}> + <Text style={styles.title}>Meu Salário</Text> + <Text style={styles.subtitle}> + Informe seu salário bruto e ajuste o líquido se necessário. + </Text> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(100).duration(400)}> + <Input + id="salary-gross-input" + label="Salário Bruto" + prefix="R$" + value={grossStr} + onChangeText={setGrossStr} + keyboardType="decimal-pad" + placeholder="0,00" + hint="Valor antes dos descontos (INSS, IRRF)" + /> + </Animated.View> + + {result && ( + <Animated.View entering={FadeInDown.delay(150).duration(400)} layout={LinearTransition.springify()}> + <Card style={styles.resultCard}> + <SectionLabel text="Cálculo Automático" /> + + <View style={styles.resultRow}> + <Text style={styles.resultLabel}>Salário Bruto</Text> + <Text style={[styles.resultValue, { color: Colors.textPrimary }]}> + {formatCurrency(result.salarioBruto)} + </Text> + </View> + + <View style={styles.resultRow}> + <Text style={styles.resultLabel}>Desconto INSS</Text> + <Text style={[styles.resultValue, { color: Colors.negative }]}> + - {formatCurrency(result.descontoINSS)} + </Text> + </View> + + <View style={styles.resultRow}> + <Text style={styles.resultLabel}>Desconto IRRF</Text> + <Text style={[styles.resultValue, { color: Colors.negative }]}> + - {formatCurrency(result.descontoIRRF)} + </Text> + </View> + + <Divider /> + + <View style={styles.resultRow}> + <Text style={[styles.resultLabel, { color: Colors.textPrimary }]}> + Salário Líquido + </Text> + <Text style={[styles.resultValue, { color: Colors.positive }]}> + {formatCurrency(result.salarioLiquidoCalculado)} + </Text> + </View> + </Card> + </Animated.View> + )} + + {result && ( + <Animated.View + entering={FadeInDown.delay(200).duration(400)} + layout={LinearTransition.springify()} + style={styles.overrideSection} + > + <Button + label={ + isOverriding + ? 'Usar valor calculado' + : 'Tenho outros descontos (ajustar manualmente)' + } + variant="ghost" + onPress={handleToggleOverride} + /> + + {isOverriding && ( + <Animated.View entering={FadeInDown.duration(300)}> + <Input + id="salary-net-override-input" + label="Salário Líquido Real" + prefix="R$" + value={netOverrideStr} + onChangeText={setNetOverrideStr} + keyboardType="decimal-pad" + placeholder="0,00" + hint="Descontos extras: plano de saúde, VT, previdência privada, etc." + /> + </Animated.View> + )} + </Animated.View> + )} + + {result && ( + <Animated.View entering={FadeInDown.delay(300).duration(400)} layout={LinearTransition.springify()}> + <Button + id="salary-save-button" + label={saved ? 'Salvo com sucesso! ✓' : 'Salvar Salário'} + fullWidth + onPress={handleSave} + disabled={!result || isSaving || saved} + loading={isSaving} + style={saved ? { backgroundColor: Colors.positive } : undefined} + /> + </Animated.View> + )} + </ScrollView> + )} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background, + }, + scroll: { + padding: Spacing.md, + gap: Spacing.lg, + }, + title: { + ...Typography.h1, + color: Colors.textPrimary, + marginBottom: Spacing.xs, + }, + subtitle: { + ...Typography.body, + color: Colors.textSecondary, + }, + resultCard: { + gap: Spacing.sm, + }, + resultRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + resultLabel: { + ...Typography.body, + color: Colors.textSecondary, + }, + resultValue: { + ...Typography.body, + fontFamily: 'DMSans_600SemiBold', + }, + overrideSection: { + gap: Spacing.md, + }, +}); |