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,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, Share,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useFilamentStore } from '@store/filamentStore';
|
||||
import { usePresetStore } from '@store/presetStore';
|
||||
import { calcFilamentPercentage } from '@domain/Filament';
|
||||
import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme';
|
||||
import { formatWeight } from '@shared/utils/filament';
|
||||
import { Card } from '@presentation/components/ui/Card';
|
||||
import { Button } from '@presentation/components/ui/Button';
|
||||
import { StockBar } from '@presentation/components/filament/StockBar';
|
||||
|
||||
/**
|
||||
* Tela de Detalhe do Filamento — 6L-0
|
||||
*/
|
||||
export default function FilamentDetailScreen(): React.ReactElement {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { filaments, removeFilament } = useFilamentStore();
|
||||
const { presets } = usePresetStore();
|
||||
|
||||
const filament = filaments.find((f) => f.id === id);
|
||||
const preset = filament ? presets.find((p) => p.id === filament.spoolPresetId) : undefined;
|
||||
|
||||
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 pct = calcFilamentPercentage(filament);
|
||||
const stockColor = getStockColor(pct);
|
||||
const slug = `${filament.brand.toLowerCase()}-${(filament.model ?? '').toLowerCase()}-${filament.colorHex.replace('#', '').toLowerCase()}`.replace(/\s+/g, '-');
|
||||
|
||||
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.back();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>
|
||||
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
|
||||
</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(app)/inventory/${id}/edit` as never)}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Ionicons name="pencil-outline" size={20} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} style={styles.headerBtn}>
|
||||
<Ionicons name="trash-outline" size={20} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* Identity Card */}
|
||||
<Card style={styles.identityCard}>
|
||||
<View style={[styles.colorSwatch, { backgroundColor: filament.colorHex }]} />
|
||||
<View style={styles.identityInfo}>
|
||||
<Text style={styles.filamentName}>
|
||||
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
|
||||
</Text>
|
||||
<Text style={styles.filamentBrandLine}>
|
||||
{filament.brand} · {filament.colorHex.toLowerCase().startsWith('#') ? '' : '#'}{filament.colorHex}
|
||||
</Text>
|
||||
<View style={styles.badgeRow}>
|
||||
<View style={styles.materialBadge}>
|
||||
<Text style={styles.materialBadgeText}>{filament.material}</Text>
|
||||
</View>
|
||||
<View style={styles.hexBadge}>
|
||||
<Text style={styles.hexBadgeText}>{filament.colorHex}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Filamento Disponível */}
|
||||
<Card>
|
||||
<View style={styles.stockHeader}>
|
||||
<Text style={styles.sectionLabel}>FILAMENTO DISPONÍVEL</Text>
|
||||
<View style={[styles.pctBadge, { backgroundColor: getStockBgColor(pct) }]}>
|
||||
<Text style={[styles.pctText, { color: stockColor }]}>{pct}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.weightRow}>
|
||||
<Text style={[styles.netWeight, { color: stockColor }]}>
|
||||
{formatWeight(filament.netWeightG)}
|
||||
</Text>
|
||||
<Text style={styles.totalWeight}>de {formatWeight(filament.totalWeightG)}</Text>
|
||||
</View>
|
||||
<StockBar percentage={pct} />
|
||||
<View style={styles.stockMeta}>
|
||||
<Text style={styles.stockMetaText}>
|
||||
Carretel: {preset?.name ?? '—'} · {preset?.spoolWeightG ?? 0}g
|
||||
</Text>
|
||||
<Text style={styles.stockMetaText}>
|
||||
Total pesado: {formatWeight(filament.totalWeightG)}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Parâmetros de Impressão */}
|
||||
<Card>
|
||||
<Text style={styles.sectionLabel}>PARÂMETROS DE IMPRESSÃO</Text>
|
||||
<View style={styles.paramsGrid}>
|
||||
<View style={styles.paramItem}>
|
||||
<Ionicons name="thermometer-outline" size={20} color={colors.stockLow} />
|
||||
<Text style={styles.paramValue}>{filament.tempHotendC ?? '—'}°C</Text>
|
||||
<Text style={styles.paramLabel}>Hotend</Text>
|
||||
</View>
|
||||
<View style={styles.paramItem}>
|
||||
<Ionicons name="flag-outline" size={20} color={colors.stockMedium} />
|
||||
<Text style={styles.paramValue}>{filament.tempBedC ?? '—'}°C</Text>
|
||||
<Text style={styles.paramLabel}>Mesa</Text>
|
||||
</View>
|
||||
<View style={styles.paramItem}>
|
||||
<Ionicons name="time-outline" size={20} color={colors.accent} />
|
||||
<Text style={styles.paramValue}>{filament.flowFactorPct ?? '—'}%</Text>
|
||||
<Text style={styles.paramLabel}>Fluxo</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Identificação */}
|
||||
<Card>
|
||||
<View style={styles.idSection}>
|
||||
{/* QR Code placeholder */}
|
||||
<View style={styles.qrPreview}>
|
||||
<Ionicons name="qr-code-outline" size={48} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.idInfo}>
|
||||
<Text style={styles.idTitle}>Identificação</Text>
|
||||
<Text style={styles.idSlug}>{slug}</Text>
|
||||
<View style={styles.idButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.idBtn}
|
||||
onPress={() => router.push(`/(app)/filaments/${id}/label` as never)}
|
||||
>
|
||||
<Ionicons name="add-outline" size={14} color={colors.accent} />
|
||||
<Text style={styles.idBtnText}>Exportar SVG</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.idBtn, styles.idBtnSecondary]}
|
||||
onPress={() => router.push(`/(app)/filaments/${id}/qrcode` as never)}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={14} color={colors.textSecondary} />
|
||||
<Text style={styles.idBtnTextSecondary}>Ver QR</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Observações */}
|
||||
{filament.notes ? (
|
||||
<Card>
|
||||
<Text style={styles.sectionLabel}>OBSERVAÇÕES</Text>
|
||||
<Text style={styles.notesText}>{filament.notes}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA fixo */}
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
label="Pesar Novamente"
|
||||
leftIcon={<Ionicons name="scale-outline" size={18} color={colors.bgBase} />}
|
||||
onPress={() => router.push(`/(app)/inventory/${id}/edit` as never)}
|
||||
/>
|
||||
</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,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerActions: { flexDirection: 'row', gap: spacing[2] },
|
||||
headerBtn: { padding: spacing[1] },
|
||||
|
||||
content: { paddingHorizontal: spacing[5], gap: spacing[4], paddingBottom: spacing[10] },
|
||||
|
||||
identityCard: { flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
|
||||
colorSwatch: { width: 72, height: 72, borderRadius: radius.lg },
|
||||
identityInfo: { flex: 1, gap: spacing[1] },
|
||||
filamentName: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.lg,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
filamentBrandLine: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
badgeRow: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[1] },
|
||||
materialBadge: {
|
||||
paddingHorizontal: spacing[3],
|
||||
paddingVertical: 3,
|
||||
backgroundColor: colors.bgHover,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
materialBadgeText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.medium,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
hexBadge: {
|
||||
paddingHorizontal: spacing[3],
|
||||
paddingVertical: 3,
|
||||
backgroundColor: colors.bgHover,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
hexBadgeText: {
|
||||
fontFamily: typography.fontFamily.mono,
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
|
||||
sectionLabel: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.textSecondary,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: spacing[3],
|
||||
},
|
||||
|
||||
stockHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing[2] },
|
||||
pctBadge: { paddingHorizontal: spacing[3], paddingVertical: 4, borderRadius: radius.sm },
|
||||
pctText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold },
|
||||
weightRow: { flexDirection: 'row', alignItems: 'baseline', gap: spacing[2], marginBottom: spacing[3] },
|
||||
netWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['3xl'], fontWeight: typography.fontWeight.bold },
|
||||
totalWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
|
||||
stockMeta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing[3] },
|
||||
stockMetaText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
|
||||
|
||||
paramsGrid: { flexDirection: 'row', justifyContent: 'space-around' },
|
||||
paramItem: { alignItems: 'center', gap: spacing[1] },
|
||||
paramValue: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xl,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
paramLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
|
||||
|
||||
idSection: { flexDirection: 'row', gap: spacing[4], alignItems: 'flex-start' },
|
||||
qrPreview: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: colors.bgHover,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
idInfo: { flex: 1, gap: spacing[2] },
|
||||
idTitle: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.base,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
idSlug: {
|
||||
fontFamily: typography.fontFamily.mono,
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
idButtons: { flexDirection: 'row', gap: spacing[2], flexWrap: 'wrap' },
|
||||
idBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: colors.accentMuted,
|
||||
paddingHorizontal: spacing[3],
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
idBtnText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.accent },
|
||||
idBtnSecondary: { backgroundColor: colors.bgHover },
|
||||
idBtnTextSecondary: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
|
||||
|
||||
notesText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: typography.fontSize.base * typography.lineHeight.relaxed,
|
||||
},
|
||||
|
||||
footer: {
|
||||
padding: spacing[5],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.bgBase,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, TouchableOpacity, StyleSheet, ScrollView,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useFilamentStore } from '@store/filamentStore';
|
||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||
import { MATERIALS, type Material } from '@shared/constants';
|
||||
import { Button } from '@presentation/components/ui/Button';
|
||||
import type { FilamentFilter } from '@domain/Filament';
|
||||
|
||||
const BRANDS = ['Elegoo', 'Bambu Lab', 'Prusament', 'Polymaker', 'Creality'];
|
||||
|
||||
type StockLevel = 'all' | 'low' | 'medium';
|
||||
|
||||
/**
|
||||
* Tela de Filtros — 17H-0
|
||||
* Renderizada como modal / bottom sheet a partir da tela de Inventário.
|
||||
*/
|
||||
export default function FiltersScreen(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const { activeFilter, setFilter, resetFilter } = useFilamentStore();
|
||||
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||
activeFilter.material ? [activeFilter.material] : [],
|
||||
);
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>(
|
||||
activeFilter.brand ? [activeFilter.brand] : [],
|
||||
);
|
||||
const [stockLevel, setStockLevel] = useState<StockLevel>(
|
||||
activeFilter.stockLevel === 'low' ? 'low' : activeFilter.stockLevel === 'medium' ? 'medium' : 'all',
|
||||
);
|
||||
|
||||
function toggleMaterial(m: Material): void {
|
||||
setSelectedMaterials((prev) =>
|
||||
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
|
||||
);
|
||||
}
|
||||
|
||||
function toggleBrand(b: string): void {
|
||||
setSelectedBrands((prev) =>
|
||||
prev.includes(b) ? prev.filter((x) => x !== b) : [...prev, b],
|
||||
);
|
||||
}
|
||||
|
||||
function handleApply(): void {
|
||||
const filter: FilamentFilter = {
|
||||
...(selectedMaterials.length === 1 ? { material: selectedMaterials[0] } : {}),
|
||||
...(selectedBrands.length === 1 ? { brand: selectedBrands[0] } : {}),
|
||||
...(stockLevel !== 'all' ? { stockLevel: stockLevel as FilamentFilter['stockLevel'] } : {}),
|
||||
sortBy: 'created_at_desc',
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
};
|
||||
setFilter(filter);
|
||||
router.back();
|
||||
}
|
||||
|
||||
function handleClear(): void {
|
||||
setSelectedMaterials([]);
|
||||
setSelectedBrands([]);
|
||||
setStockLevel('all');
|
||||
resetFilter();
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
{/* Dimmed background area (topo) */}
|
||||
<TouchableOpacity style={styles.backdrop} onPress={() => router.back()} />
|
||||
|
||||
{/* Bottom sheet */}
|
||||
<View style={styles.sheet}>
|
||||
{/* Handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Filtros</Text>
|
||||
<TouchableOpacity onPress={handleClear}>
|
||||
<Text style={styles.clearText}>Limpar tudo</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} style={styles.scroll}>
|
||||
{/* Material */}
|
||||
<Text style={styles.sectionLabel}>MATERIAL</Text>
|
||||
<View style={styles.chips}>
|
||||
{MATERIALS.map((m) => (
|
||||
<TouchableOpacity
|
||||
key={m}
|
||||
onPress={() => toggleMaterial(m)}
|
||||
style={[styles.chip, selectedMaterials.includes(m) && styles.chipActive]}
|
||||
>
|
||||
<Text style={[styles.chipText, selectedMaterials.includes(m) && styles.chipTextActive]}>
|
||||
{m}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Marca */}
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing[5] }]}>MARCA</Text>
|
||||
<View style={styles.chips}>
|
||||
{BRANDS.map((b) => (
|
||||
<TouchableOpacity
|
||||
key={b}
|
||||
onPress={() => toggleBrand(b)}
|
||||
style={[styles.chip, selectedBrands.includes(b) && styles.chipActive]}
|
||||
>
|
||||
<Text style={[styles.chipText, selectedBrands.includes(b) && styles.chipTextActive]}>
|
||||
{b}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Nível de Estoque */}
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing[5] }]}>NÍVEL DE ESTOQUE</Text>
|
||||
<View style={styles.radioGroup}>
|
||||
<TouchableOpacity
|
||||
style={[styles.radioItem, stockLevel === 'all' && styles.radioItemActive]}
|
||||
onPress={() => setStockLevel('all')}
|
||||
>
|
||||
<Text style={styles.radioLabel}>Todos</Text>
|
||||
<View style={[styles.radioCircle, stockLevel === 'all' && styles.radioCircleActive]}>
|
||||
{stockLevel === 'all' && <Ionicons name="checkmark" size={14} color={colors.bgBase} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.radioItem, stockLevel === 'medium' && styles.radioItemActive]}
|
||||
onPress={() => setStockLevel('medium')}
|
||||
>
|
||||
<View style={styles.radioLabelRow}>
|
||||
<Text style={styles.radioLabel}>Estoque baixo</Text>
|
||||
<View style={styles.radioBadge}>
|
||||
<Text style={styles.radioBadgeText}>abaixo de 25%</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.radioCircle, stockLevel === 'medium' && styles.radioCircleActive]}>
|
||||
{stockLevel === 'medium' && <Ionicons name="checkmark" size={14} color={colors.bgBase} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.radioItem, stockLevel === 'low' && styles.radioItemActive]}
|
||||
onPress={() => setStockLevel('low')}
|
||||
>
|
||||
<View style={styles.radioLabelRow}>
|
||||
<Text style={styles.radioLabel}>Quase vazio</Text>
|
||||
<View style={[styles.radioBadge, styles.radioBadgeCritical]}>
|
||||
<Text style={[styles.radioBadgeText, styles.radioBadgeTextCritical]}>abaixo de 10%</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.radioCircle, stockLevel === 'low' && styles.radioCircleActive]}>
|
||||
{stockLevel === 'low' && <Ionicons name="checkmark" size={14} color={colors.bgBase} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA */}
|
||||
<View style={styles.footer}>
|
||||
<Button label="Aplicar Filtros" onPress={handleApply} />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: 'transparent' },
|
||||
backdrop: { flex: 1, backgroundColor: colors.overlay },
|
||||
sheet: {
|
||||
backgroundColor: colors.bgSurface,
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
paddingBottom: spacing[5],
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: colors.border,
|
||||
borderRadius: radius.full,
|
||||
alignSelf: 'center',
|
||||
marginTop: spacing[3],
|
||||
marginBottom: spacing[2],
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing[5],
|
||||
paddingVertical: spacing[4],
|
||||
},
|
||||
sheetTitle: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.lg,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
clearText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.accent,
|
||||
},
|
||||
scroll: { paddingHorizontal: spacing[5], maxHeight: 440 },
|
||||
sectionLabel: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.textSecondary,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: spacing[3],
|
||||
},
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
|
||||
chip: {
|
||||
paddingHorizontal: spacing[4],
|
||||
paddingVertical: spacing[2],
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.bgHover,
|
||||
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 },
|
||||
radioGroup: { gap: spacing[2], paddingBottom: spacing[4] },
|
||||
radioItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: colors.bgHover,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing[4],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
radioItemActive: { borderColor: colors.accent },
|
||||
radioLabelRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
|
||||
radioLabel: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
radioCircle: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
radioCircleActive: { backgroundColor: colors.accent, borderColor: colors.accent },
|
||||
radioBadge: {
|
||||
backgroundColor: colors.accentMuted,
|
||||
paddingHorizontal: spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
radioBadgeCritical: { backgroundColor: 'rgba(255,107,107,0.15)' },
|
||||
radioBadgeText: {
|
||||
fontFamily: typography.fontFamily.ui,
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.accent,
|
||||
},
|
||||
radioBadgeTextCritical: { color: colors.stockLow },
|
||||
footer: { paddingHorizontal: spacing[5], paddingTop: spacing[3] },
|
||||
});
|
||||
@@ -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