diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/(app)/(tabs)/_layout.tsx | 169 | ||||
| -rw-r--r-- | src/app/(app)/(tabs)/expenses.tsx | 2 | ||||
| -rw-r--r-- | src/app/(app)/(tabs)/index.tsx | 2 | ||||
| -rw-r--r-- | src/app/(app)/(tabs)/recurring.tsx | 2 | ||||
| -rw-r--r-- | src/app/(app)/(tabs)/salary.tsx | 2 | ||||
| -rw-r--r-- | src/app/(app)/_layout.tsx | 21 | ||||
| -rw-r--r-- | src/app/_layout.tsx | 54 | ||||
| -rw-r--r-- | src/app/biometrics.tsx | 120 | ||||
| -rw-r--r-- | src/app/login.tsx | 144 | ||||
| -rw-r--r-- | src/app/register.tsx | 142 |
10 files changed, 658 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', + }, +}); |