- 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.
339 lines
16 KiB
TypeScript
339 lines
16 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
|
||
} from 'react-native';
|
||
import { 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 { 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';
|
||
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'),
|
||
model: z.string().optional(),
|
||
totalWeightG: z.coerce.number().min(1, 'Informe o peso total'),
|
||
tempHotendC: z.coerce.number().optional(),
|
||
tempBedC: z.coerce.number().optional(),
|
||
flowFactorPct: z.coerce.number().optional(),
|
||
notes: z.string().optional(),
|
||
});
|
||
|
||
type FormData = z.infer<typeof schema>;
|
||
|
||
const PRESET_COLORS = ['#E05533', '#2563EB', '#FFFFFF', '#1E1B18', '#22C55E', '#EAB308', '#EC4899', '#A855F7', '#F97316'];
|
||
|
||
/**
|
||
* Tela de Cadastro de Filamento — 2X-0
|
||
*/
|
||
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');
|
||
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
|
||
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 },
|
||
});
|
||
|
||
const totalWeight = watch('totalWeightG');
|
||
const selectedPreset = presets.find((p) => p.id === selectedPresetId);
|
||
const netWeight = selectedPreset && totalWeight
|
||
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
|
||
: 0;
|
||
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
|
||
|
||
async function onSubmit(data: FormData): Promise<void> {
|
||
if (!selectedPresetId) {
|
||
Alert.alert('Atenção', 'Selecione um preset de carretel.');
|
||
return;
|
||
}
|
||
if (!user) {
|
||
Alert.alert('Erro', 'Usuário não autenticado.');
|
||
return;
|
||
}
|
||
setIsLoading(true);
|
||
try {
|
||
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 (err) {
|
||
console.error('create filament error', err);
|
||
Alert.alert('Erro', 'Não foi possível salvar o filamento.');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safe}>
|
||
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||
{/* Header */}
|
||
<View style={styles.header}>
|
||
<TouchableOpacity onPress={() => router.back()}>
|
||
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
|
||
</TouchableOpacity>
|
||
<Text style={styles.headerTitle}>Novo Filamento</Text>
|
||
<View style={{ width: 24 }} />
|
||
</View>
|
||
|
||
<View style={styles.content}>
|
||
{/* Cor */}
|
||
<Text style={styles.sectionLabel}>COR DO FILAMENTO</Text>
|
||
<Card style={styles.colorCard}>
|
||
<View style={styles.colorPreviewRow}>
|
||
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
|
||
<Text style={styles.hexValue}>{hexInput}</Text>
|
||
<TouchableOpacity><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
|
||
</View>
|
||
<View style={styles.colorPalette}>
|
||
{PRESET_COLORS.map((c) => (
|
||
<TouchableOpacity
|
||
key={c}
|
||
onPress={() => { setSelectedColor(c); setHexInput(c); }}
|
||
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
|
||
/>
|
||
))}
|
||
<TouchableOpacity style={styles.colorAddBtn}>
|
||
<Ionicons name="add" size={18} color={colors.textSecondary} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Card>
|
||
|
||
{/* Material */}
|
||
<Text style={styles.sectionLabel}>MATERIAL</Text>
|
||
<View style={styles.materialChips}>
|
||
{MATERIALS.map((m) => (
|
||
<TouchableOpacity
|
||
key={m}
|
||
onPress={() => setSelectedMaterial(m)}
|
||
style={[styles.chip, selectedMaterial === m && styles.chipActive]}
|
||
>
|
||
<Text style={[styles.chipText, selectedMaterial === m && styles.chipTextActive]}>{m}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
{/* Marca e Modelo */}
|
||
<Controller
|
||
control={control}
|
||
name="brand"
|
||
render={({ field }) => (
|
||
<Input label="MARCA" placeholder="Elegoo" error={errors.brand?.message} onChangeText={field.onChange} value={field.value} />
|
||
)}
|
||
/>
|
||
<Controller
|
||
control={control}
|
||
name="model"
|
||
render={({ field }) => (
|
||
<Input label="MODELO (OPCIONAL)" placeholder="Ex: Rapid, Matte, Silk..." onChangeText={field.onChange} value={field.value ?? ''} />
|
||
)}
|
||
/>
|
||
|
||
{/* Parâmetros de impressão */}
|
||
<Text style={styles.sectionLabel}>PARÂMETROS DE IMPRESSÃO</Text>
|
||
<View style={styles.paramRow}>
|
||
<Controller
|
||
control={control}
|
||
name="tempHotendC"
|
||
render={({ field }) => (
|
||
<View style={styles.paramField}>
|
||
<Input
|
||
label="Hotend (°C)"
|
||
keyboardType="numeric"
|
||
onChangeText={field.onChange}
|
||
value={field.value?.toString() ?? ''}
|
||
leftIcon={<Ionicons name="thermometer-outline" size={16} color={colors.stockLow} />}
|
||
/>
|
||
</View>
|
||
)}
|
||
/>
|
||
<Controller
|
||
control={control}
|
||
name="tempBedC"
|
||
render={({ field }) => (
|
||
<View style={styles.paramField}>
|
||
<Input
|
||
label="Mesa (°C)"
|
||
keyboardType="numeric"
|
||
onChangeText={field.onChange}
|
||
value={field.value?.toString() ?? ''}
|
||
leftIcon={<Ionicons name="flag-outline" size={16} color={colors.stockMedium} />}
|
||
/>
|
||
</View>
|
||
)}
|
||
/>
|
||
<Controller
|
||
control={control}
|
||
name="flowFactorPct"
|
||
render={({ field }) => (
|
||
<View style={styles.paramField}>
|
||
<Input
|
||
label="Fluxo (%)"
|
||
keyboardType="numeric"
|
||
onChangeText={field.onChange}
|
||
value={field.value?.toString() ?? ''}
|
||
leftIcon={<Ionicons name="time-outline" size={16} color={colors.accent} />}
|
||
/>
|
||
</View>
|
||
)}
|
||
/>
|
||
</View>
|
||
|
||
{/* Preset */}
|
||
<View style={styles.presetHeader}>
|
||
<Text style={styles.sectionLabel}>PRESET DO CARRETEL</Text>
|
||
<TouchableOpacity onPress={() => router.push('/(app)/config/presets/new')}>
|
||
<Text style={styles.addPresetLink}>+ Personalizado</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
{presets.map((p) => (
|
||
<TouchableOpacity
|
||
key={p.id}
|
||
onPress={() => setSelectedPresetId(p.id)}
|
||
style={[styles.presetItem, selectedPresetId === p.id && styles.presetItemActive]}
|
||
>
|
||
<Ionicons name="disc-outline" size={20} color={selectedPresetId === p.id ? colors.accent : colors.textSecondary} />
|
||
<View style={styles.presetInfo}>
|
||
<Text style={[styles.presetName, selectedPresetId === p.id && styles.presetNameActive]}>{p.name}</Text>
|
||
{selectedPresetId === p.id && <Text style={styles.presetSub}>Carretel: {p.spoolWeightG}g</Text>}
|
||
</View>
|
||
<Text style={styles.presetWeight}>{p.spoolWeightG}g</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
|
||
{/* Calculadora */}
|
||
<Text style={styles.sectionLabel}>CALCULADORA DE PESO</Text>
|
||
<Text style={styles.calcLabel}>Peso Total na Balança (g)</Text>
|
||
<Controller
|
||
control={control}
|
||
name="totalWeightG"
|
||
render={({ field }) => (
|
||
<Card style={styles.calcInput}>
|
||
<Ionicons name="scale-outline" size={20} color={colors.textSecondary} />
|
||
<TextInput
|
||
style={styles.calcValue}
|
||
keyboardType="numeric"
|
||
placeholder="0"
|
||
placeholderTextColor={colors.textSecondary}
|
||
onChangeText={field.onChange}
|
||
value={field.value?.toString() ?? ''}
|
||
/>
|
||
<Text style={styles.calcUnit}>g</Text>
|
||
</Card>
|
||
)}
|
||
/>
|
||
|
||
{netWeight > 0 && (
|
||
<Card style={styles.resultCard}>
|
||
<View style={styles.resultTop}>
|
||
<Text style={styles.resultLabel}>Filamento Disponível</Text>
|
||
<Text style={styles.resultCalc}>{totalWeight}g – {selectedPreset?.spoolWeightG}g</Text>
|
||
</View>
|
||
<View style={styles.resultBottom}>
|
||
<Text style={styles.resultValue}>{formatWeight(netWeight)}</Text>
|
||
<View style={styles.resultPctBadge}>
|
||
<View style={[styles.resultDot, { backgroundColor: colors.accent }]} />
|
||
<Text style={styles.resultPct}>{pct}%</Text>
|
||
</View>
|
||
</View>
|
||
</Card>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
|
||
{/* CTA fixo */}
|
||
<View style={styles.footer}>
|
||
<Button
|
||
label="Salvar Filamento"
|
||
leftIcon={<Ionicons name="save-outline" size={18} color={colors.bgBase} />}
|
||
onPress={handleSubmit(onSubmit)}
|
||
isLoading={isLoading}
|
||
/>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
safe: { flex: 1, backgroundColor: 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: typography.fontWeight.semibold, color: colors.textPrimary },
|
||
content: { paddingHorizontal: spacing[5], gap: spacing[4], paddingBottom: spacing[10] },
|
||
sectionLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.bold, color: colors.textSecondary, letterSpacing: 0.8, textTransform: 'uppercase' },
|
||
colorCard: { gap: spacing[3] },
|
||
colorPreviewRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
|
||
colorSquare: { width: 40, height: 40, borderRadius: radius.sm },
|
||
hexValue: { flex: 1, fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
|
||
colorPalette: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
|
||
colorDot: { width: 32, height: 32, borderRadius: radius.full, borderWidth: 2, borderColor: 'transparent' },
|
||
colorDotActive: { borderColor: colors.accent },
|
||
colorAddBtn: { width: 32, height: 32, borderRadius: radius.full, backgroundColor: colors.bgHover, alignItems: 'center', justifyContent: 'center' },
|
||
materialChips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
|
||
chip: { paddingHorizontal: spacing[4], paddingVertical: spacing[2], borderRadius: radius.full, backgroundColor: colors.bgSurface, borderWidth: 1, borderColor: colors.border },
|
||
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
|
||
chipText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.medium, color: colors.textSecondary },
|
||
chipTextActive: { color: colors.bgBase },
|
||
paramRow: { flexDirection: 'row', gap: spacing[3] },
|
||
paramField: { flex: 1 },
|
||
presetHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||
addPresetLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.accent },
|
||
presetItem: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[4], borderWidth: 1, borderColor: colors.border },
|
||
presetItemActive: { borderColor: colors.accent },
|
||
presetInfo: { flex: 1 },
|
||
presetName: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textPrimary },
|
||
presetNameActive: { color: colors.accent },
|
||
presetSub: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
|
||
presetWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
|
||
calcLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
|
||
calcInput: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
|
||
calcValue: { flex: 1, fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
|
||
calcUnit: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
|
||
resultCard: { backgroundColor: colors.bgHover },
|
||
resultTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing[2] },
|
||
resultLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
|
||
resultCalc: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
|
||
resultBottom: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||
resultValue: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.accent },
|
||
resultPctBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: colors.accentMuted, paddingHorizontal: spacing[3], paddingVertical: 4, borderRadius: radius.sm },
|
||
resultDot: { width: 8, height: 8, borderRadius: radius.full },
|
||
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
|
||
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
|
||
});
|