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
+374
View File
@@ -0,0 +1,374 @@
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, TextInput, 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 { 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 Edição de Filamento — 13O-0
*/
export default function EditFilamentScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { presets, systemPresets } = usePresetStore();
const { filaments, updateFilament, removeFilament } = useFilamentStore();
const filament = filaments.find((f) => f.id === id);
const [selectedColor, setSelectedColor] = useState(filament?.colorHex ?? '#E05533');
const [hexInput, setHexInput] = useState(filament?.colorHex ?? '#E05533');
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
brand: filament?.brand ?? '',
model: filament?.model ?? '',
totalWeightG: filament?.totalWeightG ?? 0,
tempHotendC: filament?.tempHotendC ?? 210,
tempBedC: filament?.tempBedC ?? 60,
flowFactorPct: filament?.flowFactorPct ?? 100,
notes: filament?.notes ?? '',
},
});
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));
if (!filament) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Filamento não encontrado</Text>
<TouchableOpacity onPress={() => router.back()}>
<Text style={styles.backLink}>Voltar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
function handleDelete(): void {
Alert.alert(
'Excluir filamento',
`Deseja excluir "${filament!.brand}${filament!.model ? ` ${filament!.model}` : ''}"? Esta ação não pode ser desfeita.`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Excluir',
style: 'destructive',
onPress: () => {
removeFilament(filament!.id);
router.replace('/(app)/(tabs)/inventory');
},
},
],
);
}
async function onSubmit(data: FormData): Promise<void> {
if (!selectedPresetId) {
Alert.alert('Atenção', 'Selecione um preset de carretel.');
return;
}
setIsLoading(true);
try {
// TODO: UpdateFilamentUseCase via container DI
const updated = {
...filament!,
brand: data.brand,
model: data.model ?? null,
colorHex: selectedColor,
material: selectedMaterial,
spoolPresetId: selectedPresetId,
totalWeightG: Number(data.totalWeightG),
netWeightG: netWeight,
tempHotendC: data.tempHotendC ?? null,
tempBedC: data.tempBedC ?? null,
flowFactorPct: data.flowFactorPct ?? null,
notes: data.notes ?? null,
updatedAt: new Date().toISOString(),
};
updateFilament(updated);
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar as alterações.');
} 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}>Editar Filamento</Text>
<TouchableOpacity onPress={handleDelete}>
<Ionicons name="trash-outline" size={20} color={colors.textPrimary} />
</TouchableOpacity>
</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' as never)}>
<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 Alterações"
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 },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing[4] },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
backLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.accent },
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 },
});