Files
MeowSpool/mobile/app/(app)/inventory/[id]/edit.tsx
T
2026-03-14 19:45:08 -03:00

463 lines
22 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, Modal, KeyboardAvoidingView, Platform,
} 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 { useAuthStore } from '@store/authStore';
import { deleteFilamentUseCase, updateFilamentUseCase } from '@infrastructure/container';
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, isValidHex, normalizeHex } 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 [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInputValue, setColorInputValue] = useState('');
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>
);
}
const handleOpenColorPicker = (): void => {
setColorInputValue(selectedColor);
setIsColorPickerOpen(true);
};
const handleConfirmColor = (): void => {
if (!colorInputValue) {
Alert.alert('Erro', 'Digite um código hexadecimal');
return;
}
const normalized = normalizeHex(colorInputValue.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
setIsColorPickerOpen(false);
setColorInputValue('');
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
};
const handleCancelColor = (): void => {
setIsColorPickerOpen(false);
setColorInputValue('');
};
const handleDelete = async (): Promise<void> => {
Alert.alert(
'Confirmar Deleção',
'Tem certeza que deseja deletar este filamento?',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Deletar',
style: 'destructive',
onPress: async () => {
const user = useAuthStore.getState().user;
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado.');
return;
}
setIsLoading(true);
try {
await deleteFilamentUseCase.execute(filament!.id, user.id);
removeFilament(filament!.id);
Alert.alert('Sucesso', 'Filamento deletado.');
router.back();
} catch (err) {
console.error('delete filament error', err);
Alert.alert('Erro', 'Falha ao deletar: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
},
},
],
);
};
async function onSubmit(data: FormData): Promise<void> {
if (!selectedPresetId) {
Alert.alert('Atenção', 'Selecione um preset de carretel.');
return;
}
setIsLoading(true);
try {
const user = useAuthStore.getState().user;
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado.');
return;
}
const updated = await updateFilamentUseCase.execute(filament!.id, user.id, {
brand: data.brand,
model: data.model ?? null,
colorHex: selectedColor,
material: selectedMaterial,
spoolPresetId: selectedPresetId,
totalWeightG: Number(data.totalWeightG),
tempHotendC: data.tempHotendC ?? null,
tempBedC: data.tempBedC ?? null,
flowFactorPct: data.flowFactorPct ?? null,
notes: data.notes ?? null,
});
updateFilament(updated);
router.back();
} catch (err) {
console.error('update filament error', err);
Alert.alert('Erro', 'Falha ao atualizar: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
}
return (
<>
<Modal visible={isColorPickerOpen} transparent animationType="fade">
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalContent}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Código Hexadecimal</Text>
<TextInput
style={styles.modalInput}
placeholder="#FF5733"
placeholderTextColor={colors.textSecondary}
value={colorInputValue}
onChangeText={setColorInputValue}
autoFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity onPress={handleCancelColor} style={styles.modalBtnCancel}>
<Text style={styles.modalBtnTextCancel}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleConfirmColor} style={styles.modalBtnConfirm}>
<Text style={styles.modalBtnTextConfirm}>Confirmar</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
<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 onPress={handleOpenColorPicker}><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} onPress={handleOpenColorPicker}>
<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 },
// Modal styles
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
modalContent: { width: '85%', maxWidth: 320 },
modalCard: { backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[5], gap: spacing[4] },
modalTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
modalInput: { backgroundColor: colors.bgBase, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, paddingHorizontal: spacing[3], paddingVertical: spacing[2], fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
modalButtons: { flexDirection: 'row', gap: spacing[3] },
modalBtnCancel: { flex: 1, backgroundColor: colors.bgHover, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
modalBtnTextCancel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
modalBtnConfirm: { flex: 1, backgroundColor: colors.accent, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
modalBtnTextConfirm: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.bgBase },
});