diff options
| author | Lucas Faria Mendes <lucas.fariamo08@gmail.com> | 2026-08-14 18:06:35 +0000 |
|---|---|---|
| committer | Lucas Faria Mendes <lucas.fariamo08@gmail.com> | 2026-08-14 18:06:35 +0000 |
| commit | 2c2dfba45357bbd205a59145ae55a2779c911ae0 (patch) | |
| tree | 0736b848aed56ec2da3c2489c49da0519447703d /src | |
| download | yaba-2c2dfba45357bbd205a59145ae55a2779c911ae0.tar.gz yaba-2c2dfba45357bbd205a59145ae55a2779c911ae0.zip | |
remake
Signed-off-by: Lucas Faria Mendes <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src')
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); +} |