Files
MeowSpool/mobile/app/(app)/inventory/new.tsx
T
Felipe 9c0b3d584c feat: enhance filament management and UI improvements
- Update SpoolPresetRepository to include user_id in the update query.
- Expand .gitignore to include various environment and temporary files.
- Revise agent documentation for better clarity and formatting.
- Implement pull-to-refresh functionality in the inventory list.
- Integrate API calls for deleting and updating filaments, ensuring state synchronization.
- Add custom color picker for filament color selection with hex validation.
- Update AndroidManifest and Gradle files for improved configuration and permissions.
- Refactor MainActivity and MainApplication for better splash screen handling.
- Update styles and colors for a cohesive UI experience.
- Replace splash screen logos and icons with new assets.
2026-03-14 14:14:06 -03:00

369 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, 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';
import { 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 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);
const [isOpeningColorPicker, setIsOpeningColorPicker] = 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));
function handleOpenColorPicker(): void {
if (isOpeningColorPicker) return;
setIsOpeningColorPicker(true);
Alert.prompt(
'Cor Customizada',
'Digite um código de cor hexadecimal (ex: #FF5733)',
[
{ text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
{
text: 'Confirmar',
onPress: (value) => {
setIsOpeningColorPicker(false);
if (!value) return;
const normalized = normalizeHex(value.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
},
},
],
'plain-text',
selectedColor,
);
}
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}> onPress={handleOpenColorPicker}
<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} 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')}>
<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 },
});