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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user