Files
Felipe 34cbd4a861 feat: implement Google OAuth support for Android and iOS
- Added support for Google OAuth with separate client IDs for Android and iOS.
- Updated `verify_google_id_token` to validate `aud` against both client IDs and check `email_verified`.
- Modified `google_oauth_handler` to accept and process the new client IDs.
- Enhanced security by enforcing explicit JWT algorithm validation.
- Updated mobile app to handle Google OAuth flow using `expo-auth-session`.
- Fixed API request to send `id_token` in snake_case as expected by the backend.
- Added necessary environment variables for Google client IDs in mobile app.
- Implemented intent filter for Google OAuth redirect in AndroidManifest.xml.
2026-03-19 15:24:08 -03:00

416 lines
15 KiB
TypeScript
Raw Permalink 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, TouchableOpacity, StyleSheet, Alert, Platform,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import * as IntentLauncher from 'expo-intent-launcher';
import { useFilamentStore } from '@store/filamentStore';
import { usePresetStore } from '@store/presetStore';
import { calcFilamentPercentage } from '@domain/Filament';
import { colors, typography, spacing, radius } from '@shared/theme';
import { formatWeight } from '@shared/utils/filament';
import { Button } from '@presentation/components/ui/Button';
import { httpClient } from '@adapters/remote/httpClient';
type LabelSize = '22x14' | '50x30' | '62x29' | '38x25';
const LABEL_SIZES: { id: LabelSize; label: string; sub: string }[] = [
{ id: '22x14', label: '22 × 14', sub: 'Niimbot D11/D110' },
{ id: '50x30', label: '50 × 30', sub: 'Padrão' },
{ id: '62x29', label: '62 × 29', sub: 'Brother DK' },
{ id: '38x25', label: '38 × 25', sub: 'Dymo 11354' },
];
// Conteúdo mínimo para etiquetas muito pequenas (Niimbot)
const MINI_LABEL_CONTENT: Set<ContentOption> = new Set(['color', 'name', 'qrcode']);
type ContentOption = 'color' | 'name' | 'material_brand' | 'net_weight' | 'print_temp' | 'qrcode';
const CONTENT_OPTIONS: { id: ContentOption; label: string }[] = [
{ id: 'color', label: 'Cor visual' },
{ id: 'name', label: 'Nome' },
{ id: 'material_brand', label: 'Material · Marca' },
{ id: 'net_weight', label: 'Peso líquido' },
{ id: 'print_temp', label: 'Temp. impressão' },
{ id: 'qrcode', label: 'QR Code' },
];
/**
* Tela de Exportar Etiqueta — 1FZ-0
*/
export default function LabelScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { filaments } = useFilamentStore();
const { presets } = usePresetStore();
const filament = filaments.find((f) => f.id === id);
const preset = filament ? presets.find((p) => p.id === filament.spoolPresetId) : undefined;
const [selectedSize, setSelectedSize] = useState<LabelSize>('50x30');
const [selectedFormat, setSelectedFormat] = useState<'pdf' | 'svg'>('pdf');
const [enabledContent, setEnabledContent] = useState<Set<ContentOption>>(
new Set(['color', 'name', 'material_brand', 'net_weight', 'print_temp', 'qrcode']),
);
if (!filament) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Filamento não encontrado</Text>
</View>
</SafeAreaView>
);
}
const pct = calcFilamentPercentage(filament);
function handleSizeChange(size: LabelSize): void {
setSelectedSize(size);
if (size === '22x14') {
setEnabledContent(new Set(MINI_LABEL_CONTENT));
}
}
function toggleContent(opt: ContentOption): void {
setEnabledContent((prev) => {
const next = new Set(prev);
if (next.has(opt)) next.delete(opt); else next.add(opt);
return next;
});
}
async function handleExport(): Promise<void> {
const [widthStr, heightStr] = selectedSize.split('x');
const width_mm = Number(widthStr);
const height_mm = Number(heightStr);
const isPdf = selectedFormat === 'pdf';
const fields = Array.from(enabledContent).join(',');
// DPI alvo: 203 para Niimbot (impressora térmica de baixo custo), 96 para tela
const dpi = selectedSize === '22x14' ? 203 : undefined;
try {
if (isPdf) {
const response = await httpClient.get<ArrayBuffer>(
`/filaments/${id}/label.pdf`,
{ params: { width_mm, height_mm, fields, dpi }, responseType: 'arraybuffer' },
);
// Converte ArrayBuffer para Base64 em chunks para evitar stack overflow
// em buffers grandes (String.fromCharCode com spread falha acima de ~64KB)
const bytes = new Uint8Array(response.data);
let binary = '';
const CHUNK = 8192;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
const base64 = btoa(binary);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.pdf`;
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (Platform.OS === 'android') {
const contentUri = await FileSystem.getContentUriAsync(fileUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
type: 'application/pdf',
});
} else if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf' });
} else {
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
}
} else {
const response = await httpClient.get<string>(
`/filaments/${id}/label.svg`,
{ params: { width_mm, height_mm, fields, dpi }, responseType: 'text' },
);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
if (Platform.OS === 'android') {
const contentUri = await FileSystem.getContentUriAsync(fileUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
type: 'image/svg+xml',
});
} else if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, { mimeType: 'image/svg+xml', UTI: 'public.svg-image' });
} else {
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
}
}
} catch (err) {
Alert.alert('Erro', String(err));
}
}
const sizeLabel = LABEL_SIZES.find((s) => s.id === selectedSize);
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Exportar Etiqueta</Text>
<View style={{ width: 24 }} />
</View>
<View style={styles.content}>
{/* Label Preview */}
<Text style={styles.sectionLabel}>PREVIEW DA ETIQUETA</Text>
<View style={styles.previewCard}>
<View style={[styles.previewColorBar, { backgroundColor: filament.colorHex }]} />
<View style={styles.previewBody}>
<View style={styles.previewTop}>
<View style={styles.previewInfo}>
<View style={styles.previewNameRow}>
<View style={[styles.previewDot, { backgroundColor: filament.colorHex }]} />
<Text style={styles.previewName}>
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
</Text>
</View>
<Text style={styles.previewMeta}>
{filament.brand} · {filament.material} · {filament.colorHex}
</Text>
<View style={styles.previewParamsRow}>
<View>
<Text style={styles.previewParamLabel}>DISPONÍVEL</Text>
<Text style={styles.previewParamValue}>{formatWeight(filament.netWeightG)}</Text>
</View>
{filament.tempHotendC && (
<View>
<Text style={styles.previewParamLabel}>HOTEND</Text>
<Text style={styles.previewParamValue}>{filament.tempHotendC}°C</Text>
</View>
)}
{filament.tempBedC && (
<View>
<Text style={styles.previewParamLabel}>MESA</Text>
<Text style={styles.previewParamValue}>{filament.tempBedC}°C</Text>
</View>
)}
</View>
</View>
{/* QR placeholder */}
<Ionicons name="qr-code" size={48} color={colors.textSecondary} />
</View>
</View>
</View>
<Text style={styles.previewSize}>
<Ionicons name="resize-outline" size={12} color={colors.textSecondary} />{' '}
{sizeLabel?.label} mm ({sizeLabel?.sub})
</Text>
{/* Tamanho */}
<Text style={[styles.sectionLabel, { marginTop: spacing[2] }]}>TAMANHO</Text>
<View style={styles.sizeChips}>
{LABEL_SIZES.map((s) => (
<TouchableOpacity
key={s.id}
onPress={() => handleSizeChange(s.id)}
style={[styles.sizeChip, selectedSize === s.id && styles.sizeChipActive]}
>
<Text style={[styles.sizeChipMain, selectedSize === s.id && styles.sizeChipMainActive]}>
{s.label}
</Text>
<Text style={[styles.sizeChipSub, selectedSize === s.id && styles.sizeChipSubActive]}>
{s.sub}
</Text>
</TouchableOpacity>
))}
</View>
{/* Conteúdo */}
<Text style={styles.sectionLabel}>CONTEÚDO DA ETIQUETA</Text>
<View style={styles.contentOptions}>
{CONTENT_OPTIONS.map((opt) => {
const active = enabledContent.has(opt.id);
return (
<TouchableOpacity
key={opt.id}
onPress={() => toggleContent(opt.id)}
style={[styles.contentChip, active && styles.contentChipActive]}
>
{active && <Ionicons name="checkmark" size={12} color={colors.accent} />}
<Text style={[styles.contentChipText, active && styles.contentChipTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* CTA */}
<View style={styles.footer}>
<View style={styles.formatRow}>
{(['pdf', 'svg'] as const).map((fmt) => (
<TouchableOpacity
key={fmt}
onPress={() => setSelectedFormat(fmt)}
style={[styles.formatChip, selectedFormat === fmt && styles.formatChipActive]}
>
<Text style={[styles.formatChipText, selectedFormat === fmt && styles.formatChipTextActive]}>
{fmt.toUpperCase()}
</Text>
</TouchableOpacity>
))}
</View>
<Button
label={`Exportar ${selectedFormat.toUpperCase()}`}
leftIcon={<Ionicons name="download-outline" size={18} color={colors.bgBase} />}
onPress={handleExport}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center' },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
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: { flex: 1, paddingHorizontal: spacing[5], gap: spacing[4] },
sectionLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
previewCard: {
backgroundColor: '#F5EFE0',
borderRadius: radius.lg,
flexDirection: 'row',
overflow: 'hidden',
minHeight: 100,
},
previewColorBar: { width: 10 },
previewBody: { flex: 1, padding: spacing[3] },
previewTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' },
previewInfo: { flex: 1, gap: 4 },
previewNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
previewDot: { width: 12, height: 12, borderRadius: radius.full },
previewName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.black,
},
previewMeta: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: '#666',
},
previewParamsRow: { flexDirection: 'row', gap: spacing[4], marginTop: 4 },
previewParamLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: 9,
color: '#888',
textTransform: 'uppercase',
},
previewParamValue: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.black,
},
previewSize: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
textAlign: 'right',
marginTop: -spacing[2],
},
sizeChips: { flexDirection: 'row', gap: spacing[2] },
sizeChip: {
flex: 1,
alignItems: 'center',
paddingVertical: spacing[3],
borderRadius: radius.lg,
backgroundColor: colors.bgSurface,
borderWidth: 1,
borderColor: colors.border,
},
sizeChipActive: { borderColor: colors.accent, backgroundColor: colors.accentMuted },
sizeChipMain: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
},
sizeChipMainActive: { color: colors.accent },
sizeChipSub: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
marginTop: 2,
},
sizeChipSubActive: { color: colors.accent },
contentOptions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
contentChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing[3],
paddingVertical: spacing[2],
borderRadius: radius.full,
backgroundColor: colors.bgHover,
borderWidth: 1,
borderColor: colors.border,
},
contentChipActive: { borderColor: colors.accent },
contentChipText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
contentChipTextActive: { color: colors.accent },
footer: {
padding: spacing[5],
gap: spacing[3],
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.bgBase,
},
formatRow: { flexDirection: 'row', gap: spacing[2] },
formatChip: {
flex: 1,
alignItems: 'center',
paddingVertical: spacing[2],
borderRadius: radius.lg,
backgroundColor: colors.bgSurface,
borderWidth: 1,
borderColor: colors.border,
},
formatChipActive: { borderColor: colors.accent, backgroundColor: colors.accentMuted },
formatChipText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
},
formatChipTextActive: { color: colors.accent },
});