aboutsummaryrefslogtreecommitdiff
path: root/src/screens
diff options
context:
space:
mode:
authorLucas Faria Mendes <lucas.fariamo08@gmail.com>2026-08-14 18:06:35 +0000
committerLucas Faria Mendes <lucas.fariamo08@gmail.com>2026-08-14 18:06:35 +0000
commit2c2dfba45357bbd205a59145ae55a2779c911ae0 (patch)
tree0736b848aed56ec2da3c2489c49da0519447703d /src/screens
downloadyaba-2c2dfba45357bbd205a59145ae55a2779c911ae0.tar.gz
yaba-2c2dfba45357bbd205a59145ae55a2779c911ae0.zip
remake
Signed-off-by: Lucas Faria Mendes <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/dashboard-screen.tsx440
-rw-r--r--src/screens/one-time-expenses-screen.tsx485
-rw-r--r--src/screens/recurring-expenses-screen.tsx442
-rw-r--r--src/screens/salary-screen.tsx232
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,
+ },
+});