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:
@@ -0,0 +1,312 @@
|
||||
import React, { useState } 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 { 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';
|
||||
|
||||
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 [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);
|
||||
|
||||
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;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: CreateFilamentUseCase via container DI
|
||||
console.log('create filament', { ...data, material: selectedMaterial, colorHex: selectedColor, spoolPresetId: selectedPresetId });
|
||||
router.back();
|
||||
} catch {
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user