aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/(app)/(tabs)/_layout.tsx169
-rw-r--r--src/app/(app)/(tabs)/expenses.tsx2
-rw-r--r--src/app/(app)/(tabs)/index.tsx2
-rw-r--r--src/app/(app)/(tabs)/recurring.tsx2
-rw-r--r--src/app/(app)/(tabs)/salary.tsx2
-rw-r--r--src/app/(app)/_layout.tsx21
-rw-r--r--src/app/_layout.tsx54
-rw-r--r--src/app/biometrics.tsx120
-rw-r--r--src/app/login.tsx144
-rw-r--r--src/app/register.tsx142
-rw-r--r--src/components/animated-splash-screen.tsx103
-rw-r--r--src/components/expense-row.tsx213
-rw-r--r--src/components/ui/action-modal.tsx107
-rw-r--r--src/components/ui/button.tsx141
-rw-r--r--src/components/ui/expenses-chart.tsx187
-rw-r--r--src/components/ui/input.tsx98
-rw-r--r--src/components/ui/options-modal.tsx137
-rw-r--r--src/components/ui/primitives.tsx104
-rw-r--r--src/constants/theme.ts99
-rw-r--r--src/contexts/AuthContext.tsx107
-rw-r--r--src/global.css9
-rw-r--r--src/hooks/use-app-hooks.ts20
-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
-rw-r--r--src/services/api.ts165
-rw-r--r--src/types/index.ts98
-rw-r--r--src/utils/tax-calculator.ts85
29 files changed, 3930 insertions, 0 deletions
diff --git a/src/app/(app)/(tabs)/_layout.tsx b/src/app/(app)/(tabs)/_layout.tsx
new file mode 100644
index 0000000..5033232
--- /dev/null
+++ b/src/app/(app)/(tabs)/_layout.tsx
@@ -0,0 +1,169 @@
+import React from 'react';
+import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
+import { Tabs } from 'expo-router';
+import { BlurView } from 'expo-blur';
+import { Feather } from '@expo/vector-icons';
+import Animated, {
+ useAnimatedStyle,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated';
+import * as Haptics from 'expo-haptics';
+import { Colors, Spacing } from '@/constants/theme';
+
+function TabBarIcon({
+ name,
+ isFocused,
+}: {
+ name: keyof typeof Feather.glyphMap;
+ isFocused: boolean;
+}) {
+ const animatedIconStyle = useAnimatedStyle(() => {
+ return {
+ transform: [
+ { scale: withSpring(isFocused ? 1.15 : 1, { damping: 12, stiffness: 300 }) },
+ ],
+ };
+ });
+
+ const animatedIndicatorStyle = useAnimatedStyle(() => {
+ return {
+ opacity: withTiming(isFocused ? 1 : 0, { duration: 200 }),
+ transform: [
+ { scale: withSpring(isFocused ? 1 : 0.5, { damping: 15, stiffness: 300 }) },
+ ],
+ };
+ });
+
+ return (
+ <View style={styles.iconContainer}>
+ <Animated.View style={animatedIconStyle}>
+ <Feather
+ name={name}
+ size={22}
+ color={isFocused ? Colors.primaryLight : Colors.textMuted}
+ />
+ </Animated.View>
+ <Animated.View style={[styles.indicator, animatedIndicatorStyle]} />
+ </View>
+ );
+}
+
+function CustomTabBar({ state, descriptors, navigation }: any) {
+ return (
+ <View style={styles.tabBarContainer}>
+ <BlurView
+ intensity={Platform.OS === 'ios' ? 80 : 100}
+ tint="dark"
+ style={styles.blurView}
+ >
+ {state.routes.map((route: any, index: number) => {
+ const { options } = descriptors[route.key];
+ const isFocused = state.index === index;
+
+ const onPress = () => {
+ const event = navigation.emit({
+ type: 'tabPress',
+ target: route.key,
+ canPreventDefault: true,
+ });
+
+ if (!isFocused && !event.defaultPrevented) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ navigation.navigate(route.name);
+ }
+ };
+
+ let iconName: keyof typeof Feather.glyphMap = 'home';
+ if (route.name === 'index') iconName = 'pie-chart';
+ else if (route.name === 'salary') iconName = 'dollar-sign';
+ else if (route.name === 'recurring') iconName = 'repeat';
+ else if (route.name === 'expenses') iconName = 'plus-square';
+
+ return (
+ <TouchableOpacity
+ key={route.key}
+ accessibilityRole="button"
+ accessibilityState={isFocused ? { selected: true } : {}}
+ accessibilityLabel={options.tabBarAccessibilityLabel}
+ onPress={onPress}
+ style={styles.tabItem}
+ activeOpacity={1}
+ >
+ <TabBarIcon name={iconName} isFocused={isFocused} />
+ </TouchableOpacity>
+ );
+ })}
+ </BlurView>
+ </View>
+ );
+}
+
+export default function TabsLayout() {
+ return (
+ <Tabs
+ tabBar={(props) => <CustomTabBar {...props} />}
+ screenOptions={{
+ headerShown: false,
+ }}
+ >
+ <Tabs.Screen name="index" options={{ title: 'Dashboard' }} />
+ <Tabs.Screen name="salary" options={{ title: 'Salário' }} />
+ <Tabs.Screen name="recurring" options={{ title: 'Gastos Fixos' }} />
+ <Tabs.Screen name="expenses" options={{ title: 'Gastos do Mês' }} />
+ </Tabs>
+ );
+}
+
+const styles = StyleSheet.create({
+ tabBarContainer: {
+ position: 'absolute',
+ bottom: Platform.OS === 'ios' ? 30 : 20,
+ left: 24,
+ right: 24,
+ borderRadius: 32,
+ ...Platform.select({
+ ios: {
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.5,
+ shadowRadius: 16,
+ },
+ android: {
+ elevation: 10,
+ },
+ }),
+ },
+ blurView: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-around',
+ borderRadius: 32,
+ backgroundColor: 'rgba(22, 27, 34, 0.75)',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ overflow: 'hidden',
+ height: 64,
+ paddingHorizontal: Spacing.sm,
+ },
+ tabItem: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ },
+ iconContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 44,
+ height: 44,
+ },
+ indicator: {
+ position: 'absolute',
+ bottom: -2,
+ width: 4,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: Colors.primaryLight,
+ },
+});
diff --git a/src/app/(app)/(tabs)/expenses.tsx b/src/app/(app)/(tabs)/expenses.tsx
new file mode 100644
index 0000000..c313dc6
--- /dev/null
+++ b/src/app/(app)/(tabs)/expenses.tsx
@@ -0,0 +1,2 @@
+import OneTimeExpensesScreen from '@/screens/one-time-expenses-screen';
+export default OneTimeExpensesScreen;
diff --git a/src/app/(app)/(tabs)/index.tsx b/src/app/(app)/(tabs)/index.tsx
new file mode 100644
index 0000000..73bede0
--- /dev/null
+++ b/src/app/(app)/(tabs)/index.tsx
@@ -0,0 +1,2 @@
+import DashboardScreen from '@/screens/dashboard-screen';
+export default DashboardScreen;
diff --git a/src/app/(app)/(tabs)/recurring.tsx b/src/app/(app)/(tabs)/recurring.tsx
new file mode 100644
index 0000000..c5340ed
--- /dev/null
+++ b/src/app/(app)/(tabs)/recurring.tsx
@@ -0,0 +1,2 @@
+import RecurringExpensesScreen from '@/screens/recurring-expenses-screen';
+export default RecurringExpensesScreen;
diff --git a/src/app/(app)/(tabs)/salary.tsx b/src/app/(app)/(tabs)/salary.tsx
new file mode 100644
index 0000000..1d30bb1
--- /dev/null
+++ b/src/app/(app)/(tabs)/salary.tsx
@@ -0,0 +1,2 @@
+import SalaryScreen from '@/screens/salary-screen';
+export default SalaryScreen;
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
new file mode 100644
index 0000000..6099274
--- /dev/null
+++ b/src/app/(app)/_layout.tsx
@@ -0,0 +1,21 @@
+import { Colors } from "@/constants/theme";
+import { useAuth } from "@/contexts/AuthContext";
+import { Redirect, Slot } from "expo-router";
+import { ActivityIndicator, View } from "react-native";
+
+export default function AppLayout() {
+ const { user, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+ <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.background }}>
+ <ActivityIndicator size="large" color={Colors.primary} />
+ </View>
+ );
+ }
+
+ if (!user)
+ return <Redirect href="/login" />;
+
+ return <Slot />;
+} \ No newline at end of file
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
new file mode 100644
index 0000000..fe9f265
--- /dev/null
+++ b/src/app/_layout.tsx
@@ -0,0 +1,54 @@
+import { SplashScreen, Stack } from 'expo-router';
+import { useState, useEffect } from 'react';
+import { StatusBar } from 'expo-status-bar';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+import { useAppFonts } from '@/hooks/use-app-hooks';
+import { AuthProvider, useAuth } from '@/contexts/AuthContext';
+import { Colors } from '@/constants/theme';
+
+import { AnimatedSplashScreen } from '@/components/animated-splash-screen';
+import { View } from 'react-native';
+
+SplashScreen.preventAutoHideAsync();
+
+function RootContent() {
+ const { isLoading } = useAuth();
+ const { fontsLoaded, fontsError } = useAppFonts();
+ const [splashFinished, setSplashFinished] = useState(false);
+
+ const isAppReady = !isLoading;
+
+ useEffect(() => {
+ if (fontsLoaded || fontsError) {
+ SplashScreen.hideAsync();
+ }
+ }, [fontsLoaded, fontsError]);
+
+ if (!fontsLoaded && !fontsError)
+ return null;
+
+ return (
+ <View style={{ flex: 1, backgroundColor: Colors.background }}>
+ <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.background } }}>
+ <Stack.Screen name="(app)" />
+ <Stack.Screen name="login" />
+ <Stack.Screen name="register" />
+ </Stack>
+
+ {!splashFinished && (
+ <AnimatedSplashScreen isAppReady={isAppReady} onAnimationComplete={() => setSplashFinished(true)} />
+ )}
+ </View>
+ )
+}
+
+export default function RootLayout() {
+ return (
+ <SafeAreaProvider>
+ <StatusBar style="light" />
+ <AuthProvider>
+ <RootContent />
+ </AuthProvider>
+ </SafeAreaProvider>
+ )
+}
diff --git a/src/app/biometrics.tsx b/src/app/biometrics.tsx
new file mode 100644
index 0000000..500adc9
--- /dev/null
+++ b/src/app/biometrics.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import { useRouter } from 'expo-router';
+import * as SecureStore from 'expo-secure-store';
+import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
+import { Feather } from '@expo/vector-icons';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { Button } from '@/components/ui/button';
+import { Colors, Spacing, Typography, Radius } from '@/constants/theme';
+import { BlurView } from 'expo-blur';
+
+export default function BiometricsScreen() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+
+ async function handleAccept() {
+ await SecureStore.setItemAsync('yaba_use_biometrics', 'true');
+ router.replace('/');
+ }
+
+ function handleDecline() {
+ router.replace('/');
+ }
+
+ return (
+ <View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
+
+ <View style={styles.content}>
+ <Animated.View entering={FadeInDown.duration(600).delay(100)} style={styles.iconContainer}>
+ <View style={styles.iconBackground}>
+ <Feather size={64} color={Colors.primary} />
+ </View>
+ </Animated.View>
+
+ <Animated.View entering={FadeInUp.duration(600).delay(200)} style={styles.textContainer}>
+ <Text style={styles.title}>Login por Biometria</Text>
+ <Text style={styles.subtitle}>
+ Deseja utilizar sua biometria (Face ID / Digital) para acessar o app mais rapidamente nas próximas vezes?
+ </Text>
+ </Animated.View>
+ </View>
+
+ <Animated.View entering={FadeInUp.duration(600).delay(400)} style={styles.footer}>
+ <BlurView intensity={80} tint="dark" style={styles.blurContainer}>
+ <Button
+ label="Sim, usar biometria"
+ onPress={handleAccept}
+ fullWidth
+ style={styles.primaryButton}
+ />
+ <Button
+ label="Não, manter acesso livre"
+ variant="ghost"
+ onPress={handleDecline}
+ fullWidth
+ />
+ </BlurView>
+ </Animated.View>
+ </View>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.background,
+ justifyContent: 'space-between',
+ },
+ content: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: Spacing.xl,
+ gap: Spacing.xxl,
+ },
+ iconContainer: {
+ alignItems: 'center',
+ },
+ iconBackground: {
+ width: 120,
+ height: 120,
+ borderRadius: 60,
+ backgroundColor: 'rgba(238, 250, 13, 0.1)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(238, 250, 13, 0.2)',
+ },
+ textContainer: {
+ alignItems: 'center',
+ gap: Spacing.sm,
+ },
+ title: {
+ ...Typography.h1,
+ color: Colors.textPrimary,
+ textAlign: 'center',
+ },
+ subtitle: {
+ ...Typography.bodyLarge,
+ color: Colors.textSecondary,
+ textAlign: 'center',
+ lineHeight: 24,
+ },
+ footer: {
+ paddingHorizontal: Spacing.lg,
+ paddingBottom: Spacing.lg,
+ },
+ blurContainer: {
+ padding: Spacing.lg,
+ borderRadius: Radius.lg,
+ gap: Spacing.md,
+ backgroundColor: 'rgba(22, 27, 34, 0.6)',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ overflow: 'hidden',
+ },
+ primaryButton: {
+ height: 56,
+ }
+});
diff --git a/src/app/login.tsx b/src/app/login.tsx
new file mode 100644
index 0000000..15ec3be
--- /dev/null
+++ b/src/app/login.tsx
@@ -0,0 +1,144 @@
+import { useAuth } from "@/contexts/AuthContext";
+import { Link, useRouter } from "expo-router";
+import { useState } from "react";
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import * as LocalAuthentication from "expo-local-authentication";
+import Animated, { FadeInDown, FadeInUp } from "react-native-reanimated";
+import { Colors, Spacing, Typography } from "@/constants/theme";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+
+export default function LoginScreen() {
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { login } = useAuth();
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ async function handleLogin() {
+ if (!email || !password) {
+ Alert.alert("Erro", "Por favor, preencha todos os campos.");
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ await login({ email: email.trim(), password });
+
+ const hasHardware = await LocalAuthentication.hasHardwareAsync();
+ const isEnrolled = await LocalAuthentication.isEnrolledAsync();
+
+ if (hasHardware && isEnrolled) {
+ router.replace("/biometrics");
+ } else {
+ router.replace("/");
+ }
+ } catch (error) {
+ Alert.alert("Erro", "Falha ao fazer login. Verifique suas credenciais e tente novamente.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+ <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
+ <View style={[styles.content, { paddingTop: insets.top + Spacing.xxl }]}>
+ <Animated.View entering={FadeInUp.duration(600)} style={styles.header}>
+ <Text style={styles.title}>YABA</Text>
+ <Text style={styles.subtitle}>O seu dinheiro, no seu controle.</Text>
+ </Animated.View>
+
+ <Animated.View entering={FadeInDown.delay(200).duration(600)} style={styles.form}>
+ <Input
+ id="login-email"
+ label="E-mail"
+ placeholder="seu@email.com"
+ value={email}
+ onChangeText={setEmail}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ />
+ <Input
+ id="login-password"
+ label="Senha"
+ placeholder="********"
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ />
+
+ <Button
+ id="login-button"
+ label="Entrar"
+ fullWidth
+ onPress={handleLogin}
+ loading={isLoading}
+ disabled={isLoading}
+ />
+
+ <View style={styles.footer}>
+ <Text style={styles.footerText}>Não tem uma conta? </Text>
+ <Link href="/register" asChild>
+ <Text style={styles.linkText}>Cadastre-se</Text>
+ </Link>
+ </View>
+ </Animated.View>
+ </View>
+ </KeyboardAvoidingView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.background,
+ },
+ content: {
+ flex: 1,
+ padding: Spacing.xl,
+ justifyContent: "center",
+ },
+ header: {
+ alignItems: "center",
+ marginBottom: Spacing.xl,
+ },
+ title: {
+ ...Typography.h1,
+ fontSize: 48,
+ color: Colors.primaryLight,
+ letterSpacing: 2,
+ fontFamily: "DMSans_700Bold",
+ },
+ subtitle: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ marginTop: Spacing.xs,
+ },
+ form: {
+ gap: Spacing.lg,
+ },
+ footer: {
+ flexDirection: "row",
+ justifyContent: "center",
+ marginTop: Spacing.xl,
+ },
+ footerText: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ },
+ linkText: {
+ ...Typography.body,
+ color: Colors.primaryLight,
+ fontFamily: "DMSans_700Bold",
+ },
+}); \ No newline at end of file
diff --git a/src/app/register.tsx b/src/app/register.tsx
new file mode 100644
index 0000000..aac588d
--- /dev/null
+++ b/src/app/register.tsx
@@ -0,0 +1,142 @@
+import React, { useState } from 'react';
+import { View, StyleSheet, Text, KeyboardAvoidingView, Platform, Alert, ScrollView } from 'react-native';
+import { Link, useRouter } from 'expo-router';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
+import * as LocalAuthentication from 'expo-local-authentication';
+import * as SecureStore from 'expo-secure-store';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Colors, Spacing, Typography } from '@/constants/theme';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function RegisterScreen() {
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { register } = useAuth();
+
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ async function handleRegister() {
+ if (!name || !email || !password) {
+ Alert.alert('Erro', 'Preencha todos os campos.');
+ return;
+ }
+ try {
+ setIsLoading(true);
+ await register({ name: name.trim(), email: email.trim(), password });
+
+ const hasHardware = await LocalAuthentication.hasHardwareAsync();
+ const isEnrolled = await LocalAuthentication.isEnrolledAsync();
+
+ if (hasHardware && isEnrolled) {
+ router.replace('/biometrics');
+ } else {
+ router.replace('/');
+ }
+ } catch (err: any) {
+ Alert.alert('Erro no cadastro', 'Verifique seus dados.');
+ setIsLoading(false);
+ }
+ }
+
+ return (
+ <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
+ <ScrollView contentContainerStyle={[styles.content, { paddingTop: insets.top + Spacing.xl }]}>
+ <Animated.View entering={FadeInUp.duration(600)} style={styles.header}>
+ <Text style={styles.title}>Criar Conta</Text>
+ <Text style={styles.subtitle}>Junte-se ao YABA e organize sua vida financeira.</Text>
+ </Animated.View>
+
+ <Animated.View entering={FadeInDown.delay(200).duration(600)} style={styles.form}>
+ <Input
+ id="register-name"
+ label="Nome"
+ placeholder="Como quer ser chamado?"
+ value={name}
+ onChangeText={setName}
+ />
+ <Input
+ id="register-email"
+ label="E-mail"
+ placeholder="seu@email.com"
+ value={email}
+ onChangeText={setEmail}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ />
+ <Input
+ id="register-password"
+ label="Senha"
+ placeholder="No mínimo 6 caracteres"
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ />
+
+ <Button
+ id="register-button"
+ label="Cadastrar"
+ fullWidth
+ onPress={handleRegister}
+ loading={isLoading}
+ disabled={isLoading}
+ />
+
+ <View style={styles.footer}>
+ <Text style={styles.footerText}>Já tem uma conta? </Text>
+ <Link href="/login" asChild>
+ <Text style={styles.linkText}>Faça login</Text>
+ </Link>
+ </View>
+ </Animated.View>
+ </ScrollView>
+ </KeyboardAvoidingView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.background,
+ },
+ content: {
+ flexGrow: 1,
+ padding: Spacing.xl,
+ justifyContent: 'center',
+ },
+ header: {
+ alignItems: 'center',
+ marginBottom: Spacing.xl,
+ },
+ title: {
+ ...Typography.h1,
+ color: Colors.textPrimary,
+ },
+ subtitle: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ marginTop: Spacing.xs,
+ textAlign: 'center',
+ },
+ form: {
+ gap: Spacing.lg,
+ },
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ marginTop: Spacing.xl,
+ },
+ footerText: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ },
+ linkText: {
+ ...Typography.body,
+ color: Colors.primaryLight,
+ fontFamily: 'DMSans_700Bold',
+ },
+});
diff --git a/src/components/animated-splash-screen.tsx b/src/components/animated-splash-screen.tsx
new file mode 100644
index 0000000..7ecfdef
--- /dev/null
+++ b/src/components/animated-splash-screen.tsx
@@ -0,0 +1,103 @@
+import React, { useEffect } from 'react';
+import { StyleSheet, View, Text } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+ withSpring,
+ Easing,
+ runOnJS,
+ withDelay,
+} from 'react-native-reanimated';
+import { Colors } from '@/constants/theme';
+import { LinearGradient } from 'expo-linear-gradient';
+
+interface Props {
+ isAppReady: boolean;
+ onAnimationComplete: () => void;
+}
+
+export function AnimatedSplashScreen({ isAppReady, onAnimationComplete }: Props) {
+ const opacity = useSharedValue(1);
+ const scale = useSharedValue(1);
+ const textOpacity = useSharedValue(0);
+ const textTranslateY = useSharedValue(20);
+ const logoScale = useSharedValue(0.8);
+
+ useEffect(() => {
+ textOpacity.value = withTiming(1, { duration: 1000 });
+ textTranslateY.value = withSpring(0, { damping: 14, stiffness: 100 });
+ logoScale.value = withSpring(1, { damping: 10, stiffness: 100 });
+ }, []);
+
+ useEffect(() => {
+ if (isAppReady) {
+ opacity.value = withDelay(
+ 600,
+ withTiming(0, { duration: 700, easing: Easing.inOut(Easing.ease) }, (finished) => {
+ if (finished) {
+ runOnJS(onAnimationComplete)();
+ }
+ })
+ );
+
+ scale.value = withDelay(
+ 600,
+ withTiming(1.3, { duration: 700, easing: Easing.inOut(Easing.ease) })
+ );
+ }
+ }, [isAppReady]);
+
+ const animatedContainerStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ scale: scale.value }],
+ }));
+
+ const animatedTextStyle = useAnimatedStyle(() => ({
+ opacity: textOpacity.value,
+ transform: [
+ { translateY: textTranslateY.value },
+ { scale: logoScale.value }
+ ],
+ }));
+
+ return (
+ <Animated.View style={[styles.container, animatedContainerStyle]}>
+ <LinearGradient
+ colors={['#0D2818', Colors.background]}
+ style={StyleSheet.absoluteFill}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 0, y: 1 }}
+ />
+ <Animated.View style={animatedTextStyle}>
+ <Text style={styles.logoText}>YABA</Text>
+ <Text style={styles.subText}>Finanças Pessoais</Text>
+ </Animated.View>
+ </Animated.View>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ ...StyleSheet.absoluteFill,
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 9999,
+ },
+ logoText: {
+ fontFamily: 'Outfit_700Bold',
+ fontSize: 72,
+ color: Colors.white,
+ letterSpacing: -2,
+ textAlign: 'center',
+ },
+ subText: {
+ fontFamily: 'DMSans_500Medium',
+ fontSize: 16,
+ color: Colors.primaryLight,
+ letterSpacing: 2,
+ textTransform: 'uppercase',
+ textAlign: 'center',
+ marginTop: 8,
+ }
+});
diff --git a/src/components/expense-row.tsx b/src/components/expense-row.tsx
new file mode 100644
index 0000000..0f58c78
--- /dev/null
+++ b/src/components/expense-row.tsx
@@ -0,0 +1,213 @@
+import React from 'react';
+import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+import type { OneTimeExpense, RecurringExpense } from '@/types';
+
+const CATEGORY_EMOJI: Record<string, string> = {
+ moradia: '🏠',
+ alimentação: '🍽️',
+ transporte: '🚗',
+ saúde: '💊',
+ educação: '📚',
+ lazer: '🎬',
+ serviços: '⚙️',
+ outros: '📦',
+};
+
+interface ExpenseRowProps {
+ name: string;
+ amount: number;
+ category: string;
+ subtitle?: string;
+ index?: number;
+ onPress?: () => void;
+ rightElement?: React.ReactNode;
+ type?: 'expense' | 'income';
+}
+
+export function ExpenseRow({
+ name,
+ amount,
+ category,
+ subtitle,
+ index = 0,
+ onPress,
+ rightElement,
+ type = 'expense',
+}: ExpenseRowProps) {
+ const emoji = CATEGORY_EMOJI[category] ?? '📦';
+ const prefix = type === 'income' ? '+ ' : '- ';
+ const amountColor = type === 'income' ? Colors.positive : Colors.negative;
+
+ const formattedAmount = amount.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ minimumFractionDigits: 2,
+ });
+
+ return (
+ <Animated.View
+ entering={FadeInDown.delay(index * 60).springify()}
+ layout={LinearTransition.springify()}
+ >
+ <TouchableOpacity
+ style={styles.row}
+ onPress={onPress}
+ activeOpacity={0.7}
+ disabled={!onPress}
+ accessibilityRole="button"
+ accessibilityLabel={`${name}, ${formattedAmount}`}
+ >
+ <View style={styles.iconBox}>
+ <Text style={styles.emoji}>{emoji}</Text>
+ </View>
+
+ <View style={styles.info}>
+ <Text style={styles.name} numberOfLines={1}>
+ {name}
+ </Text>
+ {subtitle ? (
+ <Text style={styles.subtitle} numberOfLines={1}>
+ {subtitle}
+ </Text>
+ ) : null}
+ </View>
+
+ <View style={styles.right}>
+ <Text style={[styles.amount, { color: amountColor }]}>
+ {prefix}{formattedAmount}
+ </Text>
+ {rightElement}
+ </View>
+ </TouchableOpacity>
+ </Animated.View>
+ );
+}
+
+interface RecurringExpenseRowProps {
+ expense: RecurringExpense;
+ index?: number;
+ onToggle: () => void;
+ onPress: () => void;
+}
+
+export function RecurringExpenseRow({
+ expense,
+ index = 0,
+ onToggle,
+ onPress,
+}: RecurringExpenseRowProps) {
+ return (
+ <ExpenseRow
+ name={expense.name}
+ amount={expense.amount}
+ category={expense.category}
+ type={expense.type}
+ subtitle={expense.due_day ? `Vence dia ${expense.due_day}` : undefined}
+ index={index}
+ onPress={onPress}
+ rightElement={
+ <TouchableOpacity
+ style={[
+ styles.toggleBadge,
+ expense.is_active ? styles.toggleActive : styles.toggleInactive,
+ ]}
+ onPress={onToggle}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ accessibilityLabel={expense.is_active ? 'Desativar gasto' : 'Ativar gasto'}
+ accessibilityRole="switch"
+ >
+ <Text style={styles.toggleText}>
+ {expense.is_active ? 'Ativo' : 'Pausado'}
+ </Text>
+ </TouchableOpacity>
+ }
+ />
+ );
+}
+
+interface OneTimeExpenseRowProps {
+ expense: OneTimeExpense;
+ index?: number;
+ onPress: () => void;
+}
+
+export function OneTimeExpenseRow({
+ expense,
+ index = 0,
+ onPress,
+}: OneTimeExpenseRowProps) {
+ const date = new Date(expense.expense_date + 'T12:00:00');
+ const formattedDate = date.toLocaleDateString('pt-BR', {
+ day: '2-digit',
+ month: 'short',
+ });
+
+ return (
+ <ExpenseRow
+ name={expense.name}
+ amount={expense.amount}
+ category={expense.category}
+ subtitle={formattedDate}
+ index={index}
+ onPress={onPress}
+ type={expense.type}
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: Spacing.md,
+ gap: Spacing.md,
+ },
+ iconBox: {
+ width: 44,
+ height: 44,
+ borderRadius: Radius.md,
+ backgroundColor: Colors.surfaceElevated,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emoji: {
+ fontSize: 22,
+ },
+ info: {
+ flex: 1,
+ gap: 2,
+ },
+ name: {
+ ...Typography.bodyLarge,
+ color: Colors.textPrimary,
+ },
+ subtitle: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ },
+ right: {
+ alignItems: 'flex-end',
+ gap: Spacing.xs,
+ },
+ amount: {
+ ...Typography.bodyLarge,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+ toggleBadge: {
+ paddingHorizontal: Spacing.sm,
+ paddingVertical: 2,
+ borderRadius: Radius.full,
+ },
+ toggleActive: {
+ backgroundColor: `${Colors.positive}22`,
+ },
+ toggleInactive: {
+ backgroundColor: `${Colors.textMuted}22`,
+ },
+ toggleText: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ },
+});
diff --git a/src/components/ui/action-modal.tsx b/src/components/ui/action-modal.tsx
new file mode 100644
index 0000000..a1d95be
--- /dev/null
+++ b/src/components/ui/action-modal.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { BlurView } from 'expo-blur';
+import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown } from 'react-native-reanimated';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+import { Button } from './button';
+
+interface ActionModalProps {
+ visible: boolean;
+ title: string;
+ message: string;
+ confirmText?: string;
+ cancelText?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ isDestructive?: boolean;
+}
+
+export function ActionModal({
+ visible,
+ title,
+ message,
+ confirmText = 'Confirmar',
+ cancelText = 'Cancelar',
+ onConfirm,
+ onCancel,
+ isDestructive = false,
+}: ActionModalProps) {
+ if (!visible) return null;
+
+ return (
+ <Modal visible={visible} transparent animationType="none" onRequestClose={onCancel}>
+ <Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)} style={styles.overlay}>
+ <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onCancel} />
+
+ <Animated.View entering={FadeInDown.duration(300).springify()} exiting={FadeOutDown.duration(200)} style={styles.modalContainer}>
+ <BlurView intensity={80} tint="dark" style={styles.blurView}>
+ <View style={styles.content}>
+ <Text style={styles.title}>{title}</Text>
+ <Text style={styles.message}>{message}</Text>
+
+ <View style={styles.actions}>
+ <Button
+ label={cancelText}
+ variant="ghost"
+ onPress={onCancel}
+ style={styles.actionButton}
+ />
+ <Button
+ label={confirmText}
+ variant={isDestructive ? 'danger' : 'primary'}
+ onPress={onConfirm}
+ style={styles.actionButton}
+ />
+ </View>
+ </View>
+ </BlurView>
+ </Animated.View>
+ </Animated.View>
+ </Modal>
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFill,
+ },
+ modalContainer: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.xxl,
+ },
+ blurView: {
+ borderRadius: Radius.lg,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ },
+ content: {
+ padding: Spacing.xl,
+ gap: Spacing.md,
+ backgroundColor: 'rgba(22, 27, 34, 0.6)',
+ },
+ title: {
+ ...Typography.h2,
+ color: Colors.textPrimary,
+ textAlign: 'center',
+ },
+ message: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ textAlign: 'center',
+ lineHeight: 22,
+ },
+ actions: {
+ flexDirection: 'row',
+ gap: Spacing.sm,
+ marginTop: Spacing.md,
+ },
+ actionButton: {
+ flex: 1,
+ },
+});
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..3486ff7
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import {
+ ActivityIndicator,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ type TouchableOpacityProps,
+ View,
+} from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+
+const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
+
+interface ButtonProps extends TouchableOpacityProps {
+ label: string;
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
+ loading?: boolean;
+ fullWidth?: boolean;
+}
+
+export function Button({
+ label,
+ variant = 'primary',
+ loading = false,
+ fullWidth = false,
+ onPress,
+ disabled,
+ style,
+ ...rest
+}: ButtonProps) {
+ const scale = useSharedValue(1);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: scale.value }],
+ }));
+
+ function handlePressIn() {
+ scale.value = withSpring(0.96, { damping: 15, stiffness: 400 });
+ }
+
+ function handlePressOut() {
+ scale.value = withSpring(1, { damping: 15, stiffness: 400 });
+ }
+
+ const containerStyle = [
+ styles.base,
+ styles[variant],
+ fullWidth && styles.fullWidth,
+ (disabled || loading) && styles.disabled,
+ style,
+ ];
+
+ const textColor =
+ variant === 'secondary' || variant === 'ghost'
+ ? Colors.textPrimary
+ : Colors.white;
+
+ return (
+ <AnimatedTouchable
+ style={[animatedStyle, containerStyle]}
+ onPress={onPress}
+ onPressIn={handlePressIn}
+ onPressOut={handlePressOut}
+ disabled={disabled || loading}
+ activeOpacity={1}
+ {...rest}
+ >
+ {loading ? (
+ <ActivityIndicator color={textColor} size="small" />
+ ) : (
+ <Text style={[styles.label, { color: textColor }]}>{label}</Text>
+ )}
+ </AnimatedTouchable>
+ );
+}
+
+interface IconButtonProps {
+ onPress: () => void;
+ children: React.ReactNode;
+ accessibilityLabel: string;
+}
+
+export function IconButton({ onPress, children, accessibilityLabel }: IconButtonProps) {
+ return (
+ <TouchableOpacity
+ style={styles.iconButton}
+ onPress={onPress}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ accessibilityLabel={accessibilityLabel}
+ accessibilityRole="button"
+ >
+ <View>{children}</View>
+ </TouchableOpacity>
+ );
+}
+
+const styles = StyleSheet.create({
+ base: {
+ height: 52,
+ borderRadius: Radius.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: Spacing.lg,
+ minWidth: 44, // touch target mínimo
+ },
+ primary: {
+ backgroundColor: Colors.primary,
+ },
+ secondary: {
+ backgroundColor: Colors.surfaceElevated,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ ghost: {
+ backgroundColor: Colors.transparent,
+ },
+ danger: {
+ backgroundColor: Colors.negative,
+ },
+ fullWidth: {
+ width: '100%',
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+ label: {
+ ...Typography.bodyLarge,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+ iconButton: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/src/components/ui/expenses-chart.tsx b/src/components/ui/expenses-chart.tsx
new file mode 100644
index 0000000..efd5dee
--- /dev/null
+++ b/src/components/ui/expenses-chart.tsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { PieChart } from 'react-native-gifted-charts';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+import type { DashboardExpenseItem } from '@/types';
+import { Card, SectionLabel } from './primitives';
+
+interface ExpensesChartProps {
+ recurring: DashboardExpenseItem[];
+ oneTime: DashboardExpenseItem[];
+}
+
+const CATEGORY_COLORS: Record<string, string> = {
+ moradia: '#4C6EF5',
+ alimentação: '#FA5252',
+ transporte: '#FAB005',
+ saúde: '#12B886',
+ educação: '#BE4BDB',
+ lazer: '#FF922B',
+ serviços: '#228BE6',
+ outros: '#868E96',
+};
+
+const CATEGORY_LABELS: Record<string, string> = {
+ moradia: 'Moradia',
+ alimentação: 'Alimentação',
+ transporte: 'Transporte',
+ saúde: 'Saúde',
+ educação: 'Educação',
+ lazer: 'Lazer',
+ serviços: 'Serviços',
+ outros: 'Outros',
+};
+
+export function ExpensesChart({ recurring, oneTime }: ExpensesChartProps) {
+ const categoryTotals: Record<string, number> = {};
+
+ const allExpenses = [
+ ...recurring.filter(e => e.is_active && e.type === 'expense'),
+ ...oneTime.filter(e => e.type === 'expense')
+ ];
+
+ if (allExpenses.length === 0) {
+ return (
+ <Card style={styles.card}>
+ <SectionLabel text="Análise de Gastos" />
+ <View style={styles.emptyContainer}>
+ <Text style={styles.emptyText}>Não há despesas para analisar neste mês.</Text>
+ </View>
+ </Card>
+ );
+ }
+
+ let totalExpenses = 0;
+ allExpenses.forEach(e => {
+ if (!categoryTotals[e.category]) {
+ categoryTotals[e.category] = 0;
+ }
+ categoryTotals[e.category] += e.amount;
+ totalExpenses += e.amount;
+ });
+
+ const pieData = Object.keys(categoryTotals)
+ .map(category => ({
+ value: categoryTotals[category],
+ color: CATEGORY_COLORS[category] || CATEGORY_COLORS.outros,
+ label: CATEGORY_LABELS[category] || 'Outros',
+ percentage: ((categoryTotals[category] / totalExpenses) * 100).toFixed(0) + '%',
+ }))
+ .sort((a, b) => b.value - a.value);
+
+ return (
+ <Card style={styles.card}>
+ <SectionLabel text="Análise de Gastos" />
+
+ <View style={styles.chartRow}>
+ <View style={styles.chartWrapper}>
+ <PieChart
+ data={pieData}
+ donut
+ radius={70}
+ innerRadius={50}
+ innerCircleColor={Colors.surface}
+ centerLabelComponent={() => {
+ return (
+ <View style={styles.centerLabel}>
+ <Text style={styles.centerLabelValue}>
+ {totalExpenses.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ })}
+ </Text>
+ <Text style={styles.centerLabelText}>Total</Text>
+ </View>
+ );
+ }}
+ />
+ </View>
+
+ <View style={styles.legendContainer}>
+ {pieData.map((item, index) => (
+ <View key={index} style={styles.legendItem}>
+ <View style={[styles.legendDot, { backgroundColor: item.color }]} />
+ <View style={styles.legendTextWrapper}>
+ <Text style={styles.legendLabel} numberOfLines={1}>{item.label}</Text>
+ <Text style={styles.legendValue}>
+ {item.value.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ })} ({item.percentage})
+ </Text>
+ </View>
+ </View>
+ ))}
+ </View>
+ </View>
+ </Card>
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ gap: Spacing.md,
+ },
+ emptyContainer: {
+ paddingVertical: Spacing.xl,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emptyText: {
+ ...Typography.body,
+ color: Colors.textMuted,
+ },
+ chartRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ chartWrapper: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ centerLabel: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ centerLabelValue: {
+ ...Typography.body,
+ fontFamily: 'DMSans_700Bold',
+ color: Colors.textPrimary,
+ fontSize: 14,
+ },
+ centerLabelText: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ fontSize: 10,
+ },
+ legendContainer: {
+ flex: 1,
+ marginLeft: Spacing.lg,
+ gap: Spacing.sm,
+ },
+ legendItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ legendDot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ marginRight: Spacing.xs,
+ },
+ legendTextWrapper: {
+ flex: 1,
+ },
+ legendLabel: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ },
+ legendValue: {
+ ...Typography.caption,
+ color: Colors.textPrimary,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+});
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..25728df
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,98 @@
+import React, { forwardRef, useState } from 'react';
+import {
+ StyleSheet,
+ Text,
+ TextInput,
+ type TextInputProps,
+ View,
+} from 'react-native';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+
+interface InputProps extends TextInputProps {
+ label: string;
+ error?: string;
+ hint?: string;
+ prefix?: string;
+}
+
+export const Input = forwardRef<TextInput, InputProps>(function Input(
+ { label, error, hint, prefix, style, ...rest },
+ ref,
+) {
+ const [focused, setFocused] = useState(false);
+
+ return (
+ <View style={styles.wrapper}>
+ <Text style={styles.label}>{label}</Text>
+ <View
+ style={[
+ styles.inputRow,
+ focused && styles.inputRowFocused,
+ !!error && styles.inputRowError,
+ ]}
+ >
+ {prefix ? <Text style={styles.prefix}>{prefix}</Text> : null}
+ <TextInput
+ ref={ref}
+ style={[styles.input, style]}
+ placeholderTextColor={Colors.textMuted}
+ onFocus={() => setFocused(true)}
+ onBlur={() => setFocused(false)}
+ selectionColor={Colors.primaryLight}
+ {...rest}
+ />
+ </View>
+ {error ? (
+ <Text style={styles.error}>{error}</Text>
+ ) : hint ? (
+ <Text style={styles.hint}>{hint}</Text>
+ ) : null}
+ </View>
+ );
+});
+
+const styles = StyleSheet.create({
+ wrapper: {
+ gap: Spacing.xs,
+ },
+ label: {
+ ...Typography.caption,
+ color: Colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: Colors.surfaceElevated,
+ borderRadius: Radius.md,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ paddingHorizontal: Spacing.md,
+ height: 52,
+ },
+ inputRowFocused: {
+ borderColor: Colors.primaryLight,
+ },
+ inputRowError: {
+ borderColor: Colors.negative,
+ },
+ prefix: {
+ ...Typography.bodyLarge,
+ color: Colors.textSecondary,
+ marginRight: Spacing.sm,
+ },
+ input: {
+ flex: 1,
+ ...Typography.bodyLarge,
+ color: Colors.textPrimary,
+ },
+ error: {
+ ...Typography.caption,
+ color: Colors.negative,
+ },
+ hint: {
+ ...Typography.caption,
+ color: Colors.textMuted,
+ },
+});
diff --git a/src/components/ui/options-modal.tsx b/src/components/ui/options-modal.tsx
new file mode 100644
index 0000000..8d5d984
--- /dev/null
+++ b/src/components/ui/options-modal.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { BlurView } from 'expo-blur';
+import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown } from 'react-native-reanimated';
+import { Feather } from '@expo/vector-icons';
+import { Colors, Radius, Spacing, Typography } from '@/constants/theme';
+
+export interface MenuOption {
+ label: string;
+ icon?: keyof typeof Feather.glyphMap;
+ destructive?: boolean;
+ onPress: () => void;
+}
+
+interface OptionsModalProps {
+ visible: boolean;
+ title?: string;
+ options: MenuOption[];
+ onClose: () => void;
+}
+
+export function OptionsModal({ visible, title, options, onClose }: OptionsModalProps) {
+ if (!visible) return null;
+
+ return (
+ <Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
+ <Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)} style={styles.overlay}>
+ <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
+
+ <Animated.View entering={FadeInDown.duration(300).springify()} exiting={FadeOutDown.duration(200)} style={styles.modalContainer}>
+ <BlurView intensity={80} tint="dark" style={styles.blurView}>
+ <View style={styles.content}>
+ {title && <Text style={styles.title}>{title}</Text>}
+
+ <View style={styles.optionsList}>
+ {options.map((option, index) => (
+ <TouchableOpacity
+ key={index}
+ style={[styles.optionButton, index < options.length - 1 && styles.optionBorder]}
+ onPress={() => {
+ onClose();
+ setTimeout(option.onPress, 50);
+ }}
+ >
+ {option.icon && (
+ <Feather
+ name={option.icon}
+ size={20}
+ color={option.destructive ? Colors.negative : Colors.textPrimary}
+ style={styles.optionIcon}
+ />
+ )}
+ <Text style={[styles.optionText, option.destructive && styles.optionTextDestructive]}>
+ {option.label}
+ </Text>
+ </TouchableOpacity>
+ ))}
+ </View>
+
+ <TouchableOpacity style={styles.cancelButton} onPress={onClose}>
+ <Text style={styles.cancelText}>Cancelar</Text>
+ </TouchableOpacity>
+ </View>
+ </BlurView>
+ </Animated.View>
+ </Animated.View>
+ </Modal>
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFill,
+ },
+ modalContainer: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.xxl,
+ },
+ blurView: {
+ borderRadius: Radius.lg,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.08)',
+ },
+ content: {
+ padding: Spacing.md,
+ gap: Spacing.md,
+ backgroundColor: 'rgba(22, 27, 34, 0.6)',
+ },
+ title: {
+ ...Typography.body,
+ color: Colors.textSecondary,
+ textAlign: 'center',
+ marginBottom: Spacing.xs,
+ },
+ optionsList: {
+ backgroundColor: 'rgba(255,255,255,0.05)',
+ borderRadius: Radius.md,
+ overflow: 'hidden',
+ },
+ optionButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: Spacing.md,
+ backgroundColor: 'transparent',
+ },
+ optionBorder: {
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(255,255,255,0.05)',
+ },
+ optionIcon: {
+ marginRight: Spacing.sm,
+ },
+ optionText: {
+ ...Typography.bodyLarge,
+ color: Colors.textPrimary,
+ },
+ optionTextDestructive: {
+ color: Colors.negative,
+ },
+ cancelButton: {
+ padding: Spacing.md,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ borderRadius: Radius.md,
+ alignItems: 'center',
+ },
+ cancelText: {
+ ...Typography.bodyLarge,
+ color: Colors.textPrimary,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+});
diff --git a/src/components/ui/primitives.tsx b/src/components/ui/primitives.tsx
new file mode 100644
index 0000000..d598bd2
--- /dev/null
+++ b/src/components/ui/primitives.tsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { Colors, Radius, Shadow, Spacing, Typography } from '@/constants/theme';
+
+interface CardProps {
+ children: React.ReactNode;
+ style?: object;
+}
+
+export function Card({ children, style }: CardProps) {
+ return <View style={[styles.card, style]}>{children}</View>;
+}
+
+interface LabelProps {
+ text: string;
+ color?: string;
+}
+
+export function SectionLabel({ text, color = Colors.textSecondary }: LabelProps) {
+ return <Text style={[styles.label, { color }]}>{text}</Text>;
+}
+
+interface CurrencyDisplayProps {
+ amount: number;
+ size?: 'display' | 'large' | 'normal';
+ showSign?: boolean;
+}
+
+export function CurrencyDisplay({
+ amount,
+ size = 'normal',
+ showSign = false,
+}: CurrencyDisplayProps) {
+ const isNegative = amount < 0;
+ const color = isNegative ? Colors.negative : Colors.positive;
+ const formatted = Math.abs(amount).toLocaleString('pt-BR', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+ const sign = showSign ? (isNegative ? '- ' : '+ ') : '';
+
+ const textStyle =
+ size === 'display'
+ ? styles.displayAmount
+ : size === 'large'
+ ? styles.largeAmount
+ : styles.normalAmount;
+
+ return (
+ <View style={styles.currencyRow}>
+ <Text style={[styles.currencyPrefix, { color }]}>
+ {sign}R${' '}
+ </Text>
+ <Text style={[textStyle, { color }]}>{formatted}</Text>
+ </View>
+ );
+}
+
+interface DividerProps {
+ style?: object;
+}
+
+export function Divider({ style }: DividerProps) {
+ return <View style={[styles.divider, style]} />;
+}
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: Colors.surface,
+ borderRadius: Radius.lg,
+ padding: Spacing.lg,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ ...Shadow.card,
+ },
+ label: {
+ ...Typography.caption,
+ textTransform: 'uppercase',
+ letterSpacing: 1.2,
+ },
+ currencyRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ },
+ currencyPrefix: {
+ ...Typography.currencyLabel,
+ paddingBottom: 4,
+ },
+ displayAmount: {
+ ...Typography.display,
+ },
+ largeAmount: {
+ ...Typography.h1,
+ },
+ normalAmount: {
+ ...Typography.bodyLarge,
+ fontFamily: 'DMSans_600SemiBold',
+ },
+ divider: {
+ height: 1,
+ backgroundColor: Colors.border,
+ marginVertical: Spacing.md,
+ },
+});
diff --git a/src/constants/theme.ts b/src/constants/theme.ts
new file mode 100644
index 0000000..880e3d8
--- /dev/null
+++ b/src/constants/theme.ts
@@ -0,0 +1,99 @@
+export const Colors = {
+ background: '#0D1117',
+ surface: '#161B22',
+ surfaceElevated: '#1C2333',
+ border: '#2A3444',
+
+ primary: '#1A6B3C',
+ primaryLight: '#24A55A',
+ accent: '#C9A84C',
+
+ positive: '#24A55A',
+ negative: '#E05252',
+ warning: '#E0A84C',
+
+ textPrimary: '#E6EDF3',
+ textSecondary: '#8B949E',
+ textMuted: '#484F58',
+ textOnPrimary: '#FFFFFF',
+
+ white: '#FFFFFF',
+ transparent: 'transparent',
+} as const;
+
+export const Typography = {
+ display: {
+ fontFamily: 'DMSans_700Bold',
+ fontSize: 52,
+ lineHeight: 60,
+ letterSpacing: -1.5,
+ },
+ h1: {
+ fontFamily: 'DMSans_700Bold',
+ fontSize: 28,
+ lineHeight: 34,
+ letterSpacing: -0.5,
+ },
+ h2: {
+ fontFamily: 'DMSans_600SemiBold',
+ fontSize: 20,
+ lineHeight: 26,
+ letterSpacing: -0.3,
+ },
+ bodyLarge: {
+ fontFamily: 'DMSans_400Regular',
+ fontSize: 17,
+ lineHeight: 24,
+ },
+ body: {
+ fontFamily: 'DMSans_400Regular',
+ fontSize: 15,
+ lineHeight: 22,
+ },
+ caption: {
+ fontFamily: 'DMSans_400Regular',
+ fontSize: 12,
+ lineHeight: 16,
+ letterSpacing: 0.3,
+ },
+ currencyLabel: {
+ fontFamily: 'DMSans_300Light',
+ fontSize: 24,
+ lineHeight: 32,
+ letterSpacing: -0.5,
+ },
+} as const;
+
+export const Spacing = {
+ xs: 4,
+ sm: 8,
+ md: 16,
+ lg: 24,
+ xl: 32,
+ xxl: 48,
+} as const;
+
+export const Radius = {
+ sm: 8,
+ md: 12,
+ lg: 16,
+ xl: 24,
+ full: 9999,
+} as const;
+
+export const Shadow = {
+ card: {
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ glow: {
+ shadowColor: Colors.primary,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.4,
+ shadowRadius: 16,
+ elevation: 12,
+ },
+} as const;
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..d2fd7a9
--- /dev/null
+++ b/src/contexts/AuthContext.tsx
@@ -0,0 +1,107 @@
+import { User } from '@/types';
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import * as SecureStore from 'expo-secure-store';
+import * as LocalAuthentication from 'expo-local-authentication';
+import { apiLogin, apiRegister, LoginPayload, RegisterPayload, setApiToken } from '@/services/api';
+
+interface AuthContextData {
+ user: User | null;
+ token: string | null;
+ isLoading: boolean;
+ login: (data: LoginPayload) => Promise<void>;
+ register: (data: RegisterPayload) => Promise<void>;
+ logout: () => Promise<void>;
+ updateUser: (newUser: User) => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextData>({} as AuthContextData);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState<User | null>(null);
+ const [token, setToken] = useState<string | null>(null);
+ const [isLoading, setIsLoading] = useState<boolean>(true);
+
+ useEffect(() => {
+ async function loadStorageData() {
+ try {
+ const storedToken = await SecureStore.getItemAsync('yaba_token');
+ const storedUser = await SecureStore.getItemAsync('yaba_user');
+ const useBiometrics = await SecureStore.getItemAsync('yaba_use_biometrics');
+
+ if (storedToken && storedUser) {
+ let authenticated = true;
+
+ if (useBiometrics === 'true') {
+ const hasHardware = await LocalAuthentication.hasHardwareAsync();
+ const isEnrolled = await LocalAuthentication.isEnrolledAsync();
+
+ if (hasHardware && isEnrolled) {
+ const result = await LocalAuthentication.authenticateAsync({
+ promptMessage: 'Use sua digital para continuar',
+ fallbackLabel: 'Usar senha',
+ });
+
+ authenticated = result.success;
+ }
+ }
+
+ if (authenticated) {
+ setApiToken(storedToken);
+ setToken(storedToken);
+ setUser(JSON.parse(storedUser));
+ } else {
+ await SecureStore.deleteItemAsync('yaba_token');
+ await SecureStore.deleteItemAsync('yaba_user');
+ }
+ }
+ } catch (error) {
+ console.error('Error loading storage data:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ loadStorageData();
+ }, []);
+
+ async function login(data: LoginPayload) {
+ const res = await apiLogin(data);
+ await SecureStore.setItemAsync('yaba_token', res.token);
+ await SecureStore.setItemAsync('yaba_user', JSON.stringify(res.user));
+ setApiToken(res.token);
+ setToken(res.token);
+ setUser(res.user);
+ }
+
+ async function register(data: RegisterPayload) {
+ const res = await apiRegister(data);
+ await SecureStore.setItemAsync('yaba_token', res.token);
+ await SecureStore.setItemAsync('yaba_user', JSON.stringify(res.user));
+ setApiToken(res.token);
+ setToken(res.token);
+ setUser(res.user);
+ }
+
+ async function logout() {
+ await SecureStore.deleteItemAsync('yaba_token');
+ await SecureStore.deleteItemAsync('yaba_user');
+ setApiToken(null);
+ setToken(null);
+ setUser(null);
+ }
+
+ async function updateUser(newUser: User) {
+ await SecureStore.setItemAsync('yaba_user', JSON.stringify(newUser));
+ setUser(newUser);
+ }
+
+ return (
+ <AuthContext.Provider value={{ user, token, isLoading, login, register, logout, updateUser }}>
+ {children}
+ </AuthContext.Provider>
+ );
+}
+
+export function useAuth() {
+ return useContext(AuthContext);
+} \ No newline at end of file
diff --git a/src/global.css b/src/global.css
new file mode 100644
index 0000000..c8fe503
--- /dev/null
+++ b/src/global.css
@@ -0,0 +1,9 @@
+:root {
+ --font-display:
+ Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
+ Segoe UI Symbol, Noto Color Emoji;
+ --font-mono:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
+ --font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
+ --font-serif: Georgia, 'Times New Roman', serif;
+}
diff --git a/src/hooks/use-app-hooks.ts b/src/hooks/use-app-hooks.ts
new file mode 100644
index 0000000..f10d1ef
--- /dev/null
+++ b/src/hooks/use-app-hooks.ts
@@ -0,0 +1,20 @@
+import { useFonts } from 'expo-font';
+import { DMSans_300Light, DMSans_400Regular, DMSans_600SemiBold, DMSans_700Bold } from '@expo-google-fonts/dm-sans';
+import { calcularSalarioLiquido, type ResultadoCalculo } from '@/utils/tax-calculator';
+
+export function useAppFonts() {
+ const [loaded, error] = useFonts({
+ DMSans_300Light,
+ DMSans_400Regular,
+ DMSans_600SemiBold,
+ DMSans_700Bold,
+ });
+
+ return { fontsLoaded: loaded, fontsError: error };
+}
+
+export function useSalaryCalculator(grossSalaryStr: string): ResultadoCalculo | null {
+ const value = parseFloat(grossSalaryStr.replace(',', '.'));
+ if (isNaN(value) || value <= 0) return null;
+ return calcularSalarioLiquido(value);
+} \ No newline at end of file
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,
+ },
+});
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..edd376e
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,165 @@
+import { Platform } from 'react-native';
+import type { DashboardData, OneTimeExpense, RecurringExpense, User } from '@/types';
+
+export type LoginPayload = any;
+export type RegisterPayload = any;
+
+const BASE_URL = 'http://64.181.172.51:3333/api/v1'
+
+let currentToken: string | null = null;
+
+export function setApiToken(token: string | null) {
+ currentToken = token;
+}
+
+async function fetcher<T>(endpoint: string, options?: RequestInit): Promise<T> {
+ const url = `${BASE_URL}${endpoint}`;
+ try {
+ const headers: Record<string, string> = {};
+ if (options?.body) {
+ headers['Content-Type'] = 'application/json';
+ }
+ if (currentToken) {
+ headers['Authorization'] = `Bearer ${currentToken}`;
+ }
+
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ ...headers,
+ ...options?.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error(`API Error (${response.status}):`, errorText);
+ throw new Error(`API Error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error(`Fetch Error on ${endpoint}:`, error);
+ throw error;
+ }
+}
+
+export async function apiLogin(data: any): Promise<{ user: User; token: string }> {
+ const result = await fetcher<{ data: { user: User; token: string } }>('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function apiRegister(data: any): Promise<{ user: User; token: string }> {
+ const result = await fetcher<{ data: { user: User; token: string } }>('/auth/register', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function getDashboard(userId: number, month?: number, year?: number): Promise<DashboardData> {
+ const params = new URLSearchParams();
+ if (month) params.append('month', month.toString());
+ if (year) params.append('year', year.toString());
+ const qs = params.toString() ? `?${params.toString()}` : '';
+
+ const result = await fetcher<{ data: DashboardData }>(`/users/${userId}/dashboard${qs}`);
+ return result.data;
+}
+
+export async function updateUserSalary(
+ userId: number,
+ data: { gross_salary: number; net_salary_override: number | null },
+): Promise<User> {
+ const result = await fetcher<{ data: User }>(`/users/${userId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function getRecurringExpenses(userId: number): Promise<RecurringExpense[]> {
+ const result = await fetcher<{ data: RecurringExpense[] }>(`/users/${userId}/recurring-expenses`);
+ return result.data;
+}
+
+export async function createRecurringExpense(
+ userId: number,
+ data: Omit<RecurringExpense, 'id' | 'user_id' | 'created_at' | 'updated_at'>,
+): Promise<RecurringExpense> {
+ const result = await fetcher<{ data: RecurringExpense }>(`/users/${userId}/recurring-expenses`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function toggleRecurringExpense(
+ userId: number,
+ expenseId: number,
+): Promise<RecurringExpense> {
+ const result = await fetcher<{ data: RecurringExpense }>(`/users/${userId}/recurring-expenses/${expenseId}/toggle`, {
+ method: 'PATCH',
+ });
+ return result.data;
+}
+
+export async function updateRecurringExpense(
+ userId: number,
+ expenseId: number,
+ data: Partial<Omit<RecurringExpense, 'id' | 'user_id' | 'created_at' | 'updated_at'>>,
+): Promise<RecurringExpense> {
+ const result = await fetcher<{ data: RecurringExpense }>(`/users/${userId}/recurring-expenses/${expenseId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function deleteRecurringExpense(userId: number, expenseId: number): Promise<void> {
+ await fetcher(`/users/${userId}/recurring-expenses/${expenseId}`, {
+ method: 'DELETE',
+ });
+}
+
+export async function getOneTimeExpenses(userId: number, month?: number, year?: number): Promise<OneTimeExpense[]> {
+ const params = new URLSearchParams();
+ if (month) params.append('month', month.toString());
+ if (year) params.append('year', year.toString());
+ const qs = params.toString() ? `?${params.toString()}` : '';
+ const result = await fetcher<{ data: OneTimeExpense[] }>(`/users/${userId}/one-time-expenses${qs}`);
+ return result.data;
+}
+
+export async function createOneTimeExpense(
+ userId: number,
+ data: Omit<OneTimeExpense, 'id' | 'user_id' | 'created_at' | 'updated_at'>,
+): Promise<OneTimeExpense> {
+ const result = await fetcher<{ data: OneTimeExpense }>(`/users/${userId}/one-time-expenses`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function updateOneTimeExpense(
+ userId: number,
+ expenseId: number,
+ data: Partial<Omit<OneTimeExpense, 'id' | 'user_id' | 'created_at' | 'updated_at'>>,
+): Promise<OneTimeExpense> {
+ const result = await fetcher<{ data: OneTimeExpense }>(`/users/${userId}/one-time-expenses/${expenseId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ return result.data;
+}
+
+export async function deleteOneTimeExpense(userId: number, expenseId: number): Promise<void> {
+ await fetcher(`/users/${userId}/one-time-expenses/${expenseId}`, {
+ method: 'DELETE',
+ });
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..7554419
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,98 @@
+export interface User {
+ id: number;
+ name: string;
+ gross_salary: number;
+ net_salary_override: number | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Income {
+ id: number;
+ user_id: number;
+ gross_salary: number;
+ calculated_net_salary: number;
+ effective_net_salary: number;
+ reference_month: number;
+ reference_year: number;
+ description: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export type Category =
+ | 'moradia'
+ | 'alimentação'
+ | 'transporte'
+ | 'saúde'
+ | 'educação'
+ | 'lazer'
+ | 'serviços'
+ | 'outros';
+
+export interface RecurringExpense {
+ id: number;
+ user_id: number;
+ name: string;
+ amount: number;
+ category: Category;
+ type: 'expense' | 'income';
+ due_day: number | null;
+ is_active: boolean;
+ starts_at: string;
+ ends_at: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface OneTimeExpense {
+ id: number;
+ user_id: number;
+ name: string;
+ amount: number;
+ category: Category;
+ type: 'expense' | 'income';
+ expense_date: string;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface DashboardExpenseItem {
+ id: number;
+ name: string;
+ amount: number;
+ category: string;
+ type: 'expense' | 'income';
+ is_active?: boolean;
+ expense_date?: string;
+}
+
+export interface DashboardData {
+ user: {
+ id: number;
+ name: string;
+ gross_salary: number;
+ net_salary_override: number | null;
+ };
+ period: { month: number; year: number };
+ salary: {
+ gross: number;
+ calculated_net: number;
+ effective_net: number;
+ inss_discount: number;
+ irrf_discount: number;
+ has_override: boolean;
+ };
+ expenses: {
+ recurring_total: number;
+ one_time_total: number;
+ total: number;
+ recurring_list: DashboardExpenseItem[];
+ one_time_list: DashboardExpenseItem[];
+ };
+ balance: {
+ available: number;
+ expense_ratio: number;
+ };
+}
diff --git a/src/utils/tax-calculator.ts b/src/utils/tax-calculator.ts
new file mode 100644
index 0000000..64b1c08
--- /dev/null
+++ b/src/utils/tax-calculator.ts
@@ -0,0 +1,85 @@
+const INSS_TABLE = [
+ { upTo: 1_518.0, rate: 0.075 },
+ { upTo: 2_793.88, rate: 0.09 },
+ { upTo: 4_190.83, rate: 0.12 },
+ { upTo: 8_157.41, rate: 0.14 },
+] as const;
+
+const INSS_TETO = 908.86;
+
+export function calcularINSS(salarioBruto: number): number {
+ if (salarioBruto <= 0) return 0;
+ if (salarioBruto > INSS_TABLE[INSS_TABLE.length - 1].upTo) return INSS_TETO;
+
+ let inss = 0;
+ let limiteAnterior = 0;
+
+ for (const faixa of INSS_TABLE) {
+ const baseNaFaixa = Math.min(salarioBruto, faixa.upTo) - limiteAnterior;
+ if (baseNaFaixa <= 0) break;
+ inss += baseNaFaixa * faixa.rate;
+ limiteAnterior = faixa.upTo;
+ }
+
+ return parseFloat(inss.toFixed(2));
+}
+
+interface FaixaIRRF {
+ upTo: number | null;
+ rate: number;
+ deduction: number;
+}
+
+const IRRF_TABLE: FaixaIRRF[] = [
+ { upTo: 2_259.2, rate: 0, deduction: 0 },
+ { upTo: 2_826.65, rate: 0.075, deduction: 169.44 },
+ { upTo: 3_751.05, rate: 0.15, deduction: 381.44 },
+ { upTo: 4_664.68, rate: 0.225, deduction: 662.77 },
+ { upTo: null, rate: 0.275, deduction: 896.0 },
+];
+
+export function calcularIRRF(salarioBruto: number, inss: number): number {
+ const base = salarioBruto - inss;
+ if (base <= 0) return 0;
+
+ const faixa = IRRF_TABLE.find((f) => f.upTo === null || base <= f.upTo)!;
+ return parseFloat(Math.max(0, base * faixa.rate - faixa.deduction).toFixed(2));
+}
+
+export interface ResultadoCalculo {
+ salarioBruto: number;
+ descontoINSS: number;
+ descontoIRRF: number;
+ totalDescontos: number;
+ salarioLiquidoCalculado: number;
+}
+
+export function calcularSalarioLiquido(salarioBruto: number): ResultadoCalculo {
+ const descontoINSS = calcularINSS(salarioBruto);
+ const descontoIRRF = calcularIRRF(salarioBruto, descontoINSS);
+ const totalDescontos = parseFloat((descontoINSS + descontoIRRF).toFixed(2));
+ const salarioLiquidoCalculado = parseFloat(
+ (salarioBruto - totalDescontos).toFixed(2),
+ );
+ return { salarioBruto, descontoINSS, descontoIRRF, totalDescontos, salarioLiquidoCalculado };
+}
+
+export function formatCurrency(value: number): string {
+ return value.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ minimumFractionDigits: 2,
+ });
+}
+
+export function formatCurrencyCompact(value: number): string {
+ if (Math.abs(value) >= 1_000) {
+ return value.toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ });
+ }
+ return formatCurrency(value);
+}