feat: implement authentication flow and session management

- Update RootLayout to handle authentication state and redirect users based on their auth status.
- Create an Index component to redirect unauthenticated users to the login page.
- Modify ApiAuthRepository to fetch user session data using access and refresh tokens.
- Introduce a new container file to manage use case instances for authentication and filament operations.
- Enhance authStore to validate tokens and fetch user data from the API.
- Add babel-plugin-module-resolver for improved module imports.
- Update package.json scripts for running the app on Android and iOS.
- Add expo-camera dependency for camera functionalities.
- Update tsconfig.json to include infrastructure path mapping.
This commit is contained in:
2026-03-14 13:22:17 -03:00
parent d7fb768d3b
commit 416e13893c
68 changed files with 1778 additions and 99 deletions
+32 -4
View File
@@ -1,8 +1,11 @@
import React from 'react';
import { Tabs, Redirect } from 'expo-router';
import React, { useEffect } from 'react';
import { Tabs, Redirect, useRouter } from 'expo-router';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@store/authStore';
import { useFilamentStore } from '@store/filamentStore';
import { usePresetStore } from '@store/presetStore';
import { listFilamentsUseCase, listPresetsUseCase } from '@infrastructure/container';
import { colors, radius, spacing } from '@shared/theme';
/**
@@ -10,7 +13,27 @@ import { colors, radius, spacing } from '@shared/theme';
* 4 abas + FAB central: Início | Estoque | [+] | Config | Perfil
*/
export default function AppLayout(): React.ReactElement {
const { isAuthenticated } = useAuthStore();
const { isAuthenticated, user } = useAuthStore();
const router = useRouter();
const { setFilaments, setLoading, setError } = useFilamentStore();
const { setPresets } = usePresetStore();
useEffect(() => {
if (!user) return;
setLoading(true);
Promise.all([
listFilamentsUseCase.execute(user.id),
listPresetsUseCase.execute(user.id),
]).then(([filaments, presets]) => {
setFilaments(filaments);
setPresets(presets);
}).catch((err) => {
console.error('bootstrap error', err);
setError('Não foi possível carregar os dados.');
}).finally(() => {
setLoading(false);
});
}, [user, setFilaments, setPresets, setLoading, setError]);
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
@@ -54,7 +77,12 @@ export default function AppLayout(): React.ReactElement {
</View>
),
tabBarButton: (props) => (
<TouchableOpacity {...props} style={styles.fabWrapper} activeOpacity={0.8} />
<TouchableOpacity
{...props}
style={styles.fabWrapper}
activeOpacity={0.8}
onPress={() => router.push('/(app)/inventory/new')}
/>
),
}}
/>
+1 -9
View File
@@ -1,17 +1,9 @@
import React from 'react';
import { useRouter } from 'expo-router';
import { Screen } from '@presentation/components/layout/Screen';
/**
* Tab "add" — apenas redireciona para o formulário de novo filamento.
* O FAB central da tab bar chama esta rota.
* Tab "add" — tela placeholder; a navegação é interceptada pelo FAB no _layout.
*/
export default function AddTab(): React.ReactElement {
const router = useRouter();
React.useEffect(() => {
router.replace('/(app)/inventory/new');
}, [router]);
return <Screen />;
}
+1 -1
View File
@@ -56,7 +56,7 @@ export default function HomeScreen(): React.ReactElement {
</View>
<TouchableOpacity
style={styles.qrBtn}
onPress={() => router.push('/(app)/qrcode/scan')}
onPress={() => router.push('/(app)/scanner' as never)}
>
<Ionicons name="qr-code-outline" size={22} color={colors.textPrimary} />
</TouchableOpacity>
+10 -14
View File
@@ -5,6 +5,7 @@ import {
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import QRCode from 'react-native-qrcode-svg';
import { useFilamentStore } from '@store/filamentStore';
import { colors, typography, spacing, radius } from '@shared/theme';
import { Button } from '@presentation/components/ui/Button';
@@ -23,19 +24,18 @@ export default function QRCodeScreen(): React.ReactElement {
? `${filament.brand.toLowerCase()}-${(filament.model ?? '').toLowerCase()}-${filament.colorHex.replace('#', '').toLowerCase()}`.replace(/\s+/g, '-')
: 'desconhecido';
const deepLink = `meowspool.app/f/${slug}`;
const deepLink = `meowspool://filament/${id}`;
async function handleShare(): Promise<void> {
try {
await Share.share({ message: `meowspool://filament/${id}`, url: `https://${deepLink}` });
await Share.share({ message: deepLink });
} catch {
// cancelled
}
}
async function handleCopyLink(): Promise<void> {
// expo-clipboard not installed — show alert as placeholder
Alert.alert('Link copiado', deepLink);
Alert.alert('Link', deepLink);
}
if (!filament) {
@@ -75,10 +75,12 @@ export default function QRCodeScreen(): React.ReactElement {
{/* QR Code area */}
<View style={styles.qrContainer}>
<View style={styles.qrCard}>
{/* Placeholder QR — real impl would use react-native-qrcode-svg */}
<View style={styles.qrPlaceholder}>
<Ionicons name="qr-code" size={160} color={colors.black} />
</View>
<QRCode
value={deepLink}
size={200}
backgroundColor="#F5EFE0"
color="#1E1B18"
/>
</View>
<Text style={styles.qrHint}>Aponte a câmera para escanear</Text>
<View style={styles.linkBadge}>
@@ -149,12 +151,6 @@ const styles = StyleSheet.create({
borderRadius: radius.xl,
padding: spacing[6],
},
qrPlaceholder: {
width: 200,
height: 200,
alignItems: 'center',
justifyContent: 'center',
},
qrHint: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
+30 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
} from 'react-native';
@@ -10,6 +10,7 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { useFilamentStore } from '@store/filamentStore';
import { useAuthStore } from '@store/authStore';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { Card } from '@presentation/components/ui/Card';
@@ -17,6 +18,7 @@ import { calcNetWeight } from '@domain/Filament';
import { colors, typography, spacing, radius } from '@shared/theme';
import { MATERIALS, type Material } from '@shared/constants';
import { formatWeight } from '@shared/utils/filament';
import { createFilamentUseCase } from '@infrastructure/container';
const schema = z.object({
brand: z.string().min(1, 'Marca obrigatória'),
@@ -39,6 +41,7 @@ export default function NewFilamentScreen(): React.ReactElement {
const router = useRouter();
const { presets, systemPresets } = usePresetStore();
const { addFilament } = useFilamentStore();
const { user } = useAuthStore();
const [selectedColor, setSelectedColor] = useState('#E05533');
const [hexInput, setHexInput] = useState('#E05533');
@@ -46,6 +49,13 @@ export default function NewFilamentScreen(): React.ReactElement {
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
const [isLoading, setIsLoading] = useState(false);
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
useEffect(() => {
if (selectedPresetId === null && presets.length > 0) {
setSelectedPresetId(presets.find((p) => p.isSystem)?.id ?? presets[0].id);
}
}, [presets, selectedPresetId]);
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { brand: '', model: '', totalWeightG: 0, tempHotendC: 210, tempBedC: 60, flowFactorPct: 100 },
@@ -63,12 +73,28 @@ export default function NewFilamentScreen(): React.ReactElement {
Alert.alert('Atenção', 'Selecione um preset de carretel.');
return;
}
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado.');
return;
}
setIsLoading(true);
try {
// TODO: CreateFilamentUseCase via container DI
console.log('create filament', { ...data, material: selectedMaterial, colorHex: selectedColor, spoolPresetId: selectedPresetId });
const filament = await createFilamentUseCase.execute(user.id, {
material: selectedMaterial,
brand: data.brand,
model: data.model ?? null,
colorHex: selectedColor,
spoolPresetId: selectedPresetId,
totalWeightG: data.totalWeightG,
tempHotendC: data.tempHotendC ?? null,
tempBedC: data.tempBedC ?? null,
flowFactorPct: data.flowFactorPct ?? null,
notes: data.notes ?? null,
});
addFilament(filament);
router.back();
} catch {
} catch (err) {
console.error('create filament error', err);
Alert.alert('Erro', 'Não foi possível salvar o filamento.');
} finally {
setIsLoading(false);
+170
View File
@@ -0,0 +1,170 @@
import React, { useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { colors, typography, spacing } from '@shared/theme';
/**
* Tela de scanner de QR Code
* Lê deeplinks meowspool://filament/<id> e navega para o filamento.
*/
export default function ScannerScreen(): React.ReactElement {
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
const handledRef = useRef(false);
function handleBarCodeScanned({ data }: { data: string }): void {
if (handledRef.current) return;
handledRef.current = true;
const match = data.match(/meowspool:\/\/filament\/([^/]+)/);
if (match) {
router.replace(`/(app)/inventory/${match[1]}` as never);
} else {
// QR não reconhecido — libera para próximo scan
handledRef.current = false;
}
}
if (!permission) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.center}>
<Text style={styles.message}>Carregando câmera</Text>
</View>
</SafeAreaView>
);
}
if (!permission.granted) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.center}>
<Ionicons name="camera-outline" size={48} color={colors.textSecondary} />
<Text style={styles.message}>Permissão de câmera necessária</Text>
<TouchableOpacity style={styles.permBtn} onPress={requestPermission}>
<Text style={styles.permBtnText}>Conceder permissão</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Escanear QR Code</Text>
<View style={{ width: 24 }} />
</View>
{/* Camera */}
<View style={styles.cameraWrapper}>
<CameraView
style={StyleSheet.absoluteFillObject}
facing="back"
mode="picture"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
{/* Viewfinder overlay */}
<View style={styles.overlay}>
<View style={styles.viewfinder}>
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
</View>
</View>
<View style={styles.hint}>
<Text style={styles.hintText}>
Aponte para o QR Code do filamento
</Text>
</View>
</SafeAreaView>
);
}
const CORNER = 24;
const CORNER_THICKNESS = 3;
const VIEWFINDER_SIZE = 240;
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing[4], paddingHorizontal: spacing[6] },
message: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
},
permBtn: {
backgroundColor: colors.accent,
paddingHorizontal: spacing[5],
paddingVertical: spacing[3],
borderRadius: 8,
},
permBtnText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.bgBase,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: '600',
color: colors.textPrimary,
},
cameraWrapper: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.55)',
},
viewfinder: {
width: VIEWFINDER_SIZE,
height: VIEWFINDER_SIZE,
backgroundColor: 'transparent',
},
corner: {
position: 'absolute',
width: CORNER,
height: CORNER,
borderColor: colors.accent,
},
cornerTL: { top: 0, left: 0, borderTopWidth: CORNER_THICKNESS, borderLeftWidth: CORNER_THICKNESS },
cornerTR: { top: 0, right: 0, borderTopWidth: CORNER_THICKNESS, borderRightWidth: CORNER_THICKNESS },
cornerBL: { bottom: 0, left: 0, borderBottomWidth: CORNER_THICKNESS, borderLeftWidth: CORNER_THICKNESS },
cornerBR: { bottom: 0, right: 0, borderBottomWidth: CORNER_THICKNESS, borderRightWidth: CORNER_THICKNESS },
hint: {
paddingVertical: spacing[5],
paddingHorizontal: spacing[5],
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.border,
},
hintText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
});
+2 -2
View File
@@ -9,6 +9,7 @@ import { Header } from '@presentation/components/layout/Header';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
import { forgotPasswordUseCase } from '@infrastructure/container';
const schema = z.object({
email: z.string().email('E-mail inválido'),
@@ -31,8 +32,7 @@ export default function ForgotPasswordScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: ForgotPasswordUseCase
console.log('forgot-password', data);
await forgotPasswordUseCase.execute(data.email);
setSent(true);
} catch {
Alert.alert('Erro', 'Não foi possível enviar o e-mail.');
+7 -3
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Link, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -9,6 +9,8 @@ import { Screen } from '@presentation/components/layout/Screen';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
import { loginUseCase } from '@infrastructure/container';
import { useAuthStore } from '@store/authStore';
const schema = z.object({
email: z.string().email('E-mail inválido'),
@@ -23,6 +25,7 @@ type FormData = z.infer<typeof schema>;
*/
export default function LoginScreen(): React.ReactElement {
const router = useRouter();
const setSession = useAuthStore((s) => s.setSession);
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
@@ -34,10 +37,11 @@ export default function LoginScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: injetar LoginUseCase via container de DI
console.log('login', data);
const session = await loginUseCase.execute(data);
await setSession(session);
router.replace('/(app)/(tabs)/home');
} catch (err) {
console.error('login error', err);
Alert.alert('Erro', 'E-mail ou senha incorretos.');
} finally {
setIsLoading(false);
+5 -2
View File
@@ -10,6 +10,8 @@ import { Header } from '@presentation/components/layout/Header';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
import { registerUseCase } from '@infrastructure/container';
import { useAuthStore } from '@store/authStore';
const schema = z.object({
email: z.string().email('E-mail inválido'),
@@ -29,6 +31,7 @@ type FormData = z.infer<typeof schema>;
*/
export default function RegisterScreen(): React.ReactElement {
const router = useRouter();
const setSession = useAuthStore((s) => s.setSession);
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
@@ -39,8 +42,8 @@ export default function RegisterScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: injetar RegisterUseCase
console.log('register', data);
const session = await registerUseCase.execute({ email: data.email, password: data.password });
await setSession(session);
router.replace('/(auth)/verify-email');
} catch {
Alert.alert('Erro', 'Não foi possível criar sua conta.');
+15 -2
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Stack } from 'expo-router';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useAuthStore } from '@store/authStore';
@@ -8,18 +8,31 @@ import { useAuthStore } from '@store/authStore';
* Carrega a sessão armazenada e controla o fluxo auth vs app.
*/
export default function RootLayout(): React.ReactElement | null {
const { loadStoredSession, isLoading } = useAuthStore();
const { loadStoredSession, isLoading, isAuthenticated } = useAuthStore();
const router = useRouter();
const segments = useSegments();
useEffect(() => {
loadStoredSession();
}, [loadStoredSession]);
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (isAuthenticated && inAuthGroup) {
router.replace('/(app)/(tabs)/home');
} else if (!isAuthenticated && !inAuthGroup) {
router.replace('/(auth)/login');
}
}, [isAuthenticated, isLoading, segments, router]);
if (isLoading) return null;
return (
<>
<StatusBar style="light" backgroundColor="transparent" translucent />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
</Stack>
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from 'expo-router';
export default function Index() {
return <Redirect href="/(auth)/login" />;
}