feat: add domain models and repositories for user, spool presets, and filaments

- Introduced SpoolPreset and User domain models with necessary DTOs and utility functions.
- Created AuthRepository, FilamentRepository, and SpoolPresetRepository interfaces for authentication and data management.
- Implemented UI components for filament display, including ColorSwatch, FilamentCard, and StockBar.
- Developed layout components such as Header and Screen for consistent app structure.
- Added reusable UI components like Badge, Button, Card, and Input for better user interaction.
- Established global constants and theme settings for consistent styling across the application.
- Implemented utility functions for filament calculations and formatting.
- Created Zustand stores for managing authentication, filament, and preset states.
- Configured TypeScript settings for improved development experience.
This commit is contained in:
2026-03-14 10:36:13 -03:00
parent abdc2fe8ce
commit d7fb768d3b
76 changed files with 19681 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Stack, Redirect } from 'expo-router';
import { useAuthStore } from '@store/authStore';
/**
* Layout do grupo de rotas de autenticação: (auth).
* Se o usuário já estiver autenticado, redireciona para o app.
*/
export default function AuthLayout(): React.ReactElement {
const { isAuthenticated } = useAuthStore();
if (isAuthenticated) {
return <Redirect href="/(app)/(tabs)/home" />;
}
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }} />
);
}
+123
View File
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Alert } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
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';
const schema = z.object({
email: z.string().email('E-mail inválido'),
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Recuperação de Senha — L2-0
*/
export default function ForgotPasswordScreen(): React.ReactElement {
const [isLoading, setIsLoading] = useState(false);
const [sent, setSent] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '' },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: ForgotPasswordUseCase
console.log('forgot-password', data);
setSent(true);
} catch {
Alert.alert('Erro', 'Não foi possível enviar o e-mail.');
} finally {
setIsLoading(false);
}
}
return (
<Screen keyboardAvoiding>
<Header title="Recuperar senha" showBack />
<View style={styles.content}>
{sent ? (
<View style={styles.successCard}>
<Ionicons name="mail-open-outline" size={48} color={colors.accent} />
<Text style={styles.successTitle}>E-mail enviado!</Text>
<Text style={styles.successText}>
Verifique sua caixa de entrada e siga as instruções para redefinir sua senha.
</Text>
</View>
) : (
<>
<Text style={styles.description}>
Informe seu e-mail cadastrado. Enviaremos um link para você redefinir sua senha.
</Text>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
label="E-MAIL"
placeholder="seu@email.com"
keyboardType="email-address"
autoComplete="email"
leftIcon={<Ionicons name="mail-outline" size={18} color={colors.textSecondary} />}
error={errors.email?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Button label="Enviar link" onPress={handleSubmit(onSubmit)} isLoading={isLoading} style={styles.cta} />
</>
)}
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
paddingTop: spacing[6],
gap: spacing[5],
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
lineHeight: typography.fontSize.base * 1.6,
},
cta: {
marginTop: spacing[2],
},
successCard: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[4],
paddingHorizontal: spacing[4],
},
successTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
successText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
});
+213
View File
@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { View, Text, Image, 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';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
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';
const schema = z.object({
email: z.string().email('E-mail inválido'),
password: z.string().min(1, 'Senha obrigatória'),
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Login — G3-0
* E-mail + Senha, "Esqueci minha senha" e OAuth Google.
*/
export default function LoginScreen(): React.ReactElement {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: injetar LoginUseCase via container de DI
console.log('login', data);
router.replace('/(app)/(tabs)/home');
} catch (err) {
Alert.alert('Erro', 'E-mail ou senha incorretos.');
} finally {
setIsLoading(false);
}
}
async function onGoogleLogin(): Promise<void> {
setIsGoogleLoading(true);
try {
// TODO: Google Sign-In + GoogleLoginUseCase
router.replace('/(app)/(tabs)/home');
} catch {
Alert.alert('Erro', 'Não foi possível entrar com o Google.');
} finally {
setIsGoogleLoading(false);
}
}
return (
<Screen scrollable keyboardAvoiding>
{/* Logo */}
<View style={styles.hero}>
<View style={styles.logoContainer}>
<Ionicons name="paw" size={48} color={colors.accent} />
</View>
<Text style={styles.appName}>MeowSpool</Text>
<Text style={styles.tagline}>Gerencie seus filamentos com precisão.</Text>
</View>
{/* Formulário */}
<View style={styles.form}>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
label="E-MAIL"
placeholder="seu@email.com"
keyboardType="email-address"
autoComplete="email"
leftIcon={<Ionicons name="mail-outline" size={18} color={colors.textSecondary} />}
error={errors.email?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
label="SENHA"
placeholder="••••••••"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.password?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<TouchableOpacity onPress={() => router.push('/(auth)/forgot-password')} style={styles.forgotRow}>
<Text style={styles.forgotText}>Esqueci minha senha</Text>
</TouchableOpacity>
<Button label="Entrar" onPress={handleSubmit(onSubmit)} isLoading={isLoading} />
{/* Divisor */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>ou continue com</Text>
<View style={styles.dividerLine} />
</View>
{/* Google */}
<Button
label="Entrar com Google"
variant="secondary"
leftIcon={<Ionicons name="logo-google" size={18} color={colors.textPrimary} />}
onPress={onGoogleLogin}
isLoading={isGoogleLoading}
/>
{/* Criar conta */}
<View style={styles.footer}>
<Text style={styles.footerText}>Não tem uma conta? </Text>
<Link href="/(auth)/register" asChild>
<TouchableOpacity>
<Text style={styles.footerLink}>Criar conta</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
hero: {
alignItems: 'center',
paddingTop: spacing[12],
paddingBottom: spacing[10],
gap: spacing[2],
},
logoContainer: {
width: 80,
height: 80,
borderRadius: 20,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
appName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize['2xl'],
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
tagline: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
},
form: {
gap: spacing[4],
paddingBottom: spacing[8],
},
forgotRow: {
alignSelf: 'flex-end',
marginTop: -spacing[2],
},
forgotText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
marginVertical: spacing[2],
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
dividerText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing[2],
},
footerText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footerLink: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
fontWeight: typography.fontWeight.semibold,
},
});
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela de Senha Redefinida — 135-0
* Confirmação de sucesso após redefinição de senha.
*/
export default function PasswordResetDoneScreen(): React.ReactElement {
const router = useRouter();
return (
<Screen>
<View style={styles.content}>
<View style={styles.iconWrapper}>
<Ionicons name="checkmark-circle" size={64} color={colors.accent} />
</View>
<Text style={styles.title}>Senha redefinida!</Text>
<Text style={styles.description}>
Sua senha foi alterada com sucesso. Você pode fazer login com a nova senha.
</Text>
<Button
label="Ir para o Login"
onPress={() => router.replace('/(auth)/login')}
style={styles.cta}
/>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[5],
paddingHorizontal: spacing[4],
},
iconWrapper: {
width: 100,
height: 100,
borderRadius: radius.xl,
backgroundColor: 'rgba(56,188,194,0.12)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
title: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
textAlign: 'center',
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
cta: { width: '100%', marginTop: spacing[4] },
});
+146
View File
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
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';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
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';
const schema = z.object({
email: z.string().email('E-mail inválido'),
password: z
.string()
.min(8, 'Mínimo 8 caracteres'),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Cadastro — IX-0
*/
export default function RegisterScreen(): React.ReactElement {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', confirmPassword: '' },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: injetar RegisterUseCase
console.log('register', data);
router.replace('/(auth)/verify-email');
} catch {
Alert.alert('Erro', 'Não foi possível criar sua conta.');
} finally {
setIsLoading(false);
}
}
return (
<Screen scrollable keyboardAvoiding>
<Header title="Criar conta" showBack />
<View style={styles.form}>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
label="E-MAIL"
placeholder="seu@email.com"
keyboardType="email-address"
autoComplete="email"
leftIcon={<Ionicons name="mail-outline" size={18} color={colors.textSecondary} />}
error={errors.email?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
label="SENHA"
placeholder="Mínimo 8 caracteres"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.password?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field }) => (
<Input
label="CONFIRMAR SENHA"
placeholder="Repita a senha"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.confirmPassword?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Button label="Criar conta" onPress={handleSubmit(onSubmit)} isLoading={isLoading} style={styles.cta} />
<View style={styles.footer}>
<Text style={styles.footerText}> tem uma conta? </Text>
<Link href="/(auth)/login" asChild>
<TouchableOpacity>
<Text style={styles.footerLink}>Entrar</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
form: {
gap: spacing[4],
paddingTop: spacing[6],
paddingBottom: spacing[8],
},
cta: {
marginTop: spacing[2],
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing[4],
},
footerText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footerLink: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
fontWeight: typography.fontWeight.semibold,
},
});
+116
View File
@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Alert } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
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';
const schema = z.object({
password: z.string().min(8, 'Mínimo 8 caracteres'),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Redefinir Senha — 117-0
* Recebe `token` via deep link: meowspool://reset-password?token=xxx
*/
export default function ResetPasswordScreen(): React.ReactElement {
const { token } = useLocalSearchParams<{ token: string }>();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { password: '', confirmPassword: '' },
});
async function onSubmit(data: FormData): Promise<void> {
if (!token) {
Alert.alert('Erro', 'Token inválido.');
return;
}
setIsLoading(true);
try {
// TODO: ResetPasswordUseCase
console.log('reset-password', { token, ...data });
router.replace('/(auth)/password-reset-done');
} catch {
Alert.alert('Erro', 'Não foi possível redefinir a senha.');
} finally {
setIsLoading(false);
}
}
return (
<Screen keyboardAvoiding>
<Header title="Nova senha" showBack />
<View style={styles.content}>
<Text style={styles.description}>
Escolha uma nova senha segura para sua conta.
</Text>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
label="NOVA SENHA"
placeholder="Mínimo 8 caracteres"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.password?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field }) => (
<Input
label="CONFIRMAR NOVA SENHA"
placeholder="Repita a senha"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.confirmPassword?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Button label="Redefinir senha" onPress={handleSubmit(onSubmit)} isLoading={isLoading} style={styles.cta} />
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
paddingTop: spacing[6],
gap: spacing[4],
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
lineHeight: typography.fontSize.base * 1.6,
marginBottom: spacing[2],
},
cta: { marginTop: spacing[2] },
});
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela de Verificação de E-mail — RU-0
* Mostrada após o cadastro bem-sucedido.
*/
export default function VerifyEmailScreen(): React.ReactElement {
return (
<Screen>
<Header title="Verificar e-mail" showBack />
<View style={styles.content}>
<View style={styles.iconWrapper}>
<Ionicons name="mail-outline" size={64} color={colors.accent} />
</View>
<Text style={styles.title}>Confirme seu e-mail</Text>
<Text style={styles.description}>
Enviamos um link de verificação para o seu e-mail. Clique no link para ativar sua conta e poder fazer login.
</Text>
<View style={styles.hint}>
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
<Text style={styles.hintText}>Não recebeu? Verifique sua pasta de spam.</Text>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[5],
paddingHorizontal: spacing[4],
},
iconWrapper: {
width: 100,
height: 100,
borderRadius: radius.xl,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
title: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
textAlign: 'center',
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
hint: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[2],
marginTop: spacing[4],
},
hintText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
});