Files
MeowSpool/mobile/app/(app)/inventory/[id]/edit.tsx
T
Felipe d7fb768d3b 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.
2026-03-14 10:36:13 -03:00

375 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
});