This commit is contained in:
2026-03-14 19:45:08 -03:00
parent 9ac83ad823
commit 74adc35b53
23 changed files with 1082 additions and 150 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"mcp__paper__get_basic_info",
"mcp__paper__get_tree_summary",
"mcp__paper__get_screenshot",
"mcp__paper__get_children",
"mcp__paper__get_computed_styles",
"mcp__paper__get_jsx",
"mcp__paper__write_html",
"mcp__paper__create_artboard",
"mcp__paper__delete_nodes",
"mcp__paper__finish_working_on_nodes"
]
}
}
+138
View File
@@ -0,0 +1,138 @@
# MeowSpool Mobile — TODO
> Última atualização: 14/03/2026
---
## Crítico — Funcionalidades Quebradas
### Auth
- [ ] **Reset de senha não executado** (`app/(auth)/reset-password.tsx`)
- `onSubmit` apenas faz `console.log` e navega para `password-reset-done`
- `resetPasswordUseCase` já existe em `src/infrastructure/container.ts`
- Chamar `resetPasswordUseCase.execute({ token, newPassword })` no submit
- O `token` deve ser lido dos params de navegação (vindo do deep link do email)
- Exibir `Alert.alert` em caso de erro da API
- [ ] **Verificar email — sem resend** (`app/(auth)/verify-email.tsx`)
- Tela é puramente estática
- Adicionar botão "Reenviar código"
- Criar `ResendVerificationUseCase` ou reutilizar `forgotPasswordUseCase` conforme API
- Exibir feedback de sucesso/erro no reenvio
### Filtros de Inventário — Desconexão Store/Lista
- [ ] **`inventory.tsx` não consome o `activeFilter` do store** (`app/(app)/(tabs)/inventory.tsx`)
- `filters.tsx` salva corretamente em `filamentStore.setFilter()`
- `inventory.tsx` filtra o array local com estado interno, ignorando `activeFilter` por completo
- Refatorar: ler `activeFilter` do store e aplicar junto com a busca local
- Ou: ao fechar `filters.tsx`, disparar `listFilamentsUseCase.execute()` com os filtros ativos
---
## Médio — Funcionalidades Incompletas
### Google OAuth
- [ ] **Login com Google é stub** (`app/(auth)/login.tsx`)
- Botão navega direto para `/(app)/(tabs)/home` sem nenhuma autenticação real
- Instalar e configurar `expo-auth-session` + `expo-web-browser`
- Obter `id_token` do Google via `AuthSession.startAsync`
- Chamar `googleLoginUseCase.execute({ idToken })` (use case já existe em `container.ts`)
- Configurar `GOOGLE_CLIENT_ID` em `.env` e `app.json`
### Exportar Etiqueta SVG
- [ ] **Botão de exportar é stub** (`app/(app)/filaments/[id]/label.tsx`)
- Botão "Exportar SVG" exibe apenas um `Alert.alert` informativo
- Chamar `GET /filaments/:id/label.svg?width_mm=W&height_mm=H` via `httpClient`
- Usar `expo-file-system` para salvar o SVG localmente
- Usar `expo-sharing` para abrir o sheet de compartilhamento nativo
- Passar `width_mm` e `height_mm` conforme o tamanho selecionado na UI
### QR Code — Copiar Link
- [ ] **"Copiar link" exibe Alert em vez de copiar** (`app/(app)/filaments/[id]/qrcode.tsx`)
- Instalar `expo-clipboard` (ou usar `@react-native-clipboard/clipboard`)
- Substituir o `Alert.alert` por `Clipboard.setStringAsync(deepLink)`
- Exibir feedback visual breve (toast ou mudança de ícone por 2s)
### Perfil — Trocar Email / Senha
- [ ] **Botões de edição sem navegação** (`app/(app)/(tabs)/profile.tsx`)
- Botões "Trocar email" e "Trocar senha" existem na UI mas não navegam para nenhuma tela
- Criar telas `app/(app)/profile/change-email.tsx` e `app/(app)/profile/change-password.tsx`
- Chamar `PUT /users/me` (quando backend implementar `update_me_handler`)
---
## Baixo — UX / Qualidade
- [ ] **Sort de filamentos sem ação** (`app/(app)/(tabs)/inventory.tsx`)
- Botão de sort está renderizado mas não tem `onPress` com lógica
- Implementar seleção de ordem: `net_weight_asc`, `net_weight_desc`, `created_at_desc`
- Passar `sort` como parâmetro no `listFilamentsUseCase.execute()`
- Ou ordenar o array local sem nova chamada à API
- [ ] **Tipos `Dashboard.ts` usados em `HomeScreen`** (`app/(app)/(tabs)/home.tsx`)
- `src/domain/Dashboard.ts` define `DashboardData`, `MaterialSummary`, etc.
- `HomeScreen` calcula tudo inline sem usar esses tipos
- Refatorar para tipar explicitamente os dados computados com as interfaces do domínio
- Opcional: criar `DashboardUseCase` que chama `GET /dashboard` e retorna `DashboardData`
---
## Arquitetura — Offline-First (Fase Futura)
A infraestrutura está parcialmente preparada. Os itens abaixo compõem a implementação completa:
- [ ] **`SyncService`** — classe que drena a `sync_queue` e envia para a API
- Criar `src/application/sync/SyncService.ts`
- Ler linhas de `sync_queue` onde `synced = 0`
- Para cada item: chamar o use case correspondente (create/update/delete)
- Em sucesso: marcar `synced = 1` na tabela de origem e deletar da `sync_queue`
- Em conflito 409: aplicar last-write-wins (aceitar versão do servidor, atualizar local)
- [ ] **Registrar background task** (`src/infrastructure/backgroundSync.ts` — novo arquivo)
- Instalar `expo-background-fetch` + `expo-task-manager`
- Registrar task `BACKGROUND_SYNC` com `TaskManager.defineTask`
- Chamar `SyncService.sync()` dentro da task
- Registrar com `BackgroundFetch.registerTaskAsync` (intervalo mínimo: 15min)
- [ ] **Injetar adapters locais no container** (`src/infrastructure/container.ts`)
- Substituir `ApiFilamentRepository` por wrapper que escreve no SQLite e enfileira na `sync_queue`
- Substituir `ApiSpoolPresetRepository` por `LocalSpoolPresetRepository`
- `LocalSpoolPresetRepository.upsertSystemPresets()` já existe para sincronizar presets do sistema
- [ ] **Modo offline — fallback na leitura**
- Ao detectar ausência de rede (`NetInfo`), usar `LocalFilamentRepository` como fonte de leitura
- Exibir badge "Offline" no header quando sem conexão
---
## Testes
- [ ] Configurar Jest + Testing Library (`@testing-library/react-native`)
- [ ] Testes unitários para use cases (`CreateFilamentUseCase`, cálculo de net weight)
- [ ] Testes de integração para stores Zustand
- [ ] Testes de snapshot para componentes `FilamentCard`, `StockBar`, `Badge`
- [ ] Mock de `httpClient` para testes de repositórios remotos
---
## Referência Rápida — Arquivos por Área
| Área | Arquivo |
| ------------------------------------ | ------------------------------------- |
| Login / Google OAuth | `app/(auth)/login.tsx` |
| Reset de senha | `app/(auth)/reset-password.tsx` |
| Verificar email | `app/(auth)/verify-email.tsx` |
| Lista de inventário + filtros + sort | `app/(app)/(tabs)/inventory.tsx` |
| Filtros (bottom sheet) | `app/(app)/inventory/filters.tsx` |
| Etiqueta SVG | `app/(app)/filaments/[id]/label.tsx` |
| QR Code | `app/(app)/filaments/[id]/qrcode.tsx` |
| Perfil | `app/(app)/(tabs)/profile.tsx` |
| DI Container | `src/infrastructure/container.ts` |
| DB local / schema | `src/adapters/local/database.ts` |
| Domínio Dashboard | `src/domain/Dashboard.ts` |
+88 -13
View File
@@ -12,8 +12,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
| Componente | Biblioteca | Observação |
| ----------------- | ----------------------------------- | --------------------------------------------------------- |
| Framework | `expo` ~51 | Managed Workflow |
| Navigation | `expo-router` ~3 | File-system routing |
| Framework | `expo` ~54 | Managed Workflow |
| Navigation | `expo-router` ~4 | File-system routing |
| Linguagem | TypeScript 5.x | strict mode |
| Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) |
| Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) |
@@ -28,6 +28,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
| Animations | `react-native-reanimated` | |
| QR Code render | `react-native-qrcode-svg` | Render de QR Code em tela |
| Câmera / Scanner | `expo-camera` ~17 | ML Kit barcode scan; requer development build |
| File system | `expo-file-system/legacy` | Salvar arquivos no cache; usar import `/legacy` |
| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos |
---
@@ -105,7 +107,7 @@ mobile/
│ └── filaments/
│ └── [id]/
│ ├── qrcode.tsx ← Ver QR Code (react-native-qrcode-svg)
│ └── label.tsx ← Exportar Etiqueta SVG
│ └── label.tsx ← Exportar Etiqueta (PDF ou SVG)
└── src/
├── domain/
@@ -296,15 +298,16 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
### Filaments
| Método | Rota | Descrição |
| ------ | ----------------------- | -------------------- |
| GET | `/filaments` | Listar (com filtros) |
| POST | `/filaments` | Criar |
| GET | `/filaments/:id` | Detalhe |
| PATCH | `/filaments/:id` | Atualizar |
| DELETE | `/filaments/:id` | Excluir |
| GET | `/filaments/:id/qrcode` | QR Code SVG |
| GET | `/filaments/:id/label` | Etiqueta SVG |
| Método | Rota | Descrição |
| ------ | --------------------------- | -------------------------------------------- |
| GET | `/filaments` | Listar (com filtros) |
| POST | `/filaments` | Criar |
| GET | `/filaments/:id` | Detalhe |
| PATCH | `/filaments/:id` | Atualizar |
| DELETE | `/filaments/:id` | Excluir |
| GET | `/filaments/:id/qrcode` | QR Code PNG |
| GET | `/filaments/:id/label.svg` | Etiqueta SVG (`width_mm`, `height_mm`, `fields`) |
| GET | `/filaments/:id/label.pdf` | Etiqueta PDF (`width_mm`, `height_mm`, `fields`) |
### Spool Presets
@@ -329,7 +332,9 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
- [ ] Implementar sync background com `sync_queue` SQLite → API
- [x] Integrar `react-native-qrcode-svg` para render real do QR Code
- [x] Scanner de QR Code com `expo-camera` v17 (ML Kit) → `scanner.tsx`
- [ ] Gerar SVG de etiqueta (integração com `/filaments/:id/label` do backend)
- [x] Exportar etiqueta SVG via `GET /filaments/:id/label.svg`
- [x] Exportar etiqueta PDF via `GET /filaments/:id/label.pdf`
- [x] Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta
- [ ] Expo Notifications para alertas de estoque baixo
- [ ] Google OAuth com `expo-auth-session`
- [ ] Testes de integração com Jest + Testing Library
@@ -338,6 +343,15 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
## Mudanças Recentes (14/03/2026)
### ✅ Exportação de Etiqueta PDF e SVG (`label.tsx`)
- **Pacotes instalados**: `expo-file-system`, `expo-sharing`, `react-native-worklets`
- **Atenção**: importar `expo-file-system` como `expo-file-system/legacy` — a API padrão do SDK 54 não exporta `EncodingType` nem `writeAsStringAsync` diretamente
- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer``btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → `Sharing.shareAsync`
- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'``Sharing.shareAsync`
- **Seletor de formato**: chips PDF/SVG no footer; padrão é PDF
- **Campos selecionáveis**: `enabledContent` (Set) é convertido para string CSV e enviado como `fields` query param — o backend respeita a seleção
### ✅ Implementações Completadas
#### 1. **Delete Filament — Integração com API**
@@ -510,6 +524,67 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
- **Resultado**: Ícone agora exibe com fundo turquesa arredondado, alinhado com design do Paper
- **Impacto**: Visual correto da tela de login (G3-0) sincronizado com mockups
#### 7. **Color Picker — Modal Customizada (Substituindo Alert.prompt)**
- **Arquivos**: `app/(app)/inventory/new.tsx`, `app/(app)/inventory/[id]/edit.tsx`
- **Problema**: `Alert.prompt()` apresentava bugs e comportamentos inconsistentes em React Native/Expo
- **Solução**: Implementada Modal customizada com componentes nativos
- **Mudanças**:
- Adicionadas importações: `Modal`, `KeyboardAvoidingView`, `Platform` do React Native
- Novos estados: `isColorPickerOpen` (boolean), `colorInputValue` (string)
- Funções de controle:
- `handleOpenColorPicker()`: abre modal focando no input
- `handleConfirmColor()`: valida hex, atualiza cor, fecha modal
- `handleCancelColor()`: descarta input, fecha modal
- JSX: Modal envolta em Fragment com overlay semi-transparente
- Estilos novos: `modalOverlay`, `modalContent`, `modalCard`, `modalInput`, `modalButtons`, etc.
- **Código de exemplo**:
```typescript
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInputValue, setColorInputValue] = useState('');
const handleConfirmColor = (): void => {
if (!colorInputValue) {
Alert.alert('Erro', 'Digite um código hexadecimal');
return;
}
const normalized = normalizeHex(colorInputValue.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
setIsColorPickerOpen(false);
setColorInputValue('');
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
};
// No JSX:
<Modal visible={isColorPickerOpen} transparent animationType="fade">
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.modalCard}>
<TextInput
placeholder="#FF5733"
value={colorInputValue}
onChangeText={setColorInputValue}
autoFocus
/>
{/* Botões confirmar/cancelar */}
</View>
</KeyboardAvoidingView>
</View>
</Modal>
```
- **Impacto**:
- ✅ Color picker agora funciona consistentemente em todas plataformas
- ✅ UX melhorada com feedback visual imediato
- ✅ Suporte completo a teclado (autoFocus, KeyboardAvoidingView)
- ✅ Validação de hex pré-digitação com `isValidHex()` e `normalizeHex()`
### Arquitetura Mantida
- Todos use cases injetados via `container.ts` (Dependency Injection)
+19 -1
View File
@@ -6,8 +6,10 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { useAuthStore } from '@store/authStore';
import { isUserOwnedPreset } from '@domain/SpoolPreset';
import { colors, typography, spacing, radius } from '@shared/theme';
import { deletePresetUseCase } from '@infrastructure/container';
/**
* Tela de Presets de Carretéis — M4-0
@@ -22,7 +24,23 @@ export default function ConfigScreen(): React.ReactElement {
`Deseja excluir o preset "${name}"?`,
[
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Excluir', style: 'destructive', onPress: () => removePreset(id) },
{
text: 'Excluir',
style: 'destructive',
onPress: async () => {
try {
const user = useAuthStore.getState().user;
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado');
return;
}
await deletePresetUseCase.execute(id, user.id);
removePreset(id);
} catch (error) {
Alert.alert('Erro', `Falha ao deletar: ${(error as Error).message}`);
}
},
},
],
);
}
+30 -9
View File
@@ -9,9 +9,12 @@ import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { useAuthStore } from '@store/authStore';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
import type { UpdateSpoolPresetInput } from '@domain/SpoolPreset';
import { updatePresetUseCase, deletePresetUseCase } from '@infrastructure/container';
const schema = z.object({
name: z.string().min(1, 'Nome obrigatório'),
@@ -64,9 +67,20 @@ export default function EditPresetScreen(): React.ReactElement {
{
text: 'Excluir',
style: 'destructive',
onPress: () => {
removePreset(preset!.id);
router.replace('/(app)/(tabs)/config' as never);
onPress: async () => {
try {
const user = useAuthStore.getState().user;
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado');
return;
}
await deletePresetUseCase.execute(preset!.id, user.id);
removePreset(preset!.id);
router.replace('/(app)/(tabs)/config' as never);
} catch (error) {
Alert.alert('Erro', `Falha ao deletar: ${(error as Error).message}`);
}
},
},
],
@@ -76,15 +90,22 @@ export default function EditPresetScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: UpdatePresetUseCase via DI container
updatePreset({
...preset!,
const user = useAuthStore.getState().user;
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado');
return;
}
const input: UpdateSpoolPresetInput = {
name: data.name,
spoolWeightG: Number(data.spoolWeightG),
});
};
const updated = await updatePresetUseCase.execute(preset!.id, user.id, input);
updatePreset(updated);
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar as alterações.');
} catch (error) {
Alert.alert('Erro', `Não foi possível salvar as alterações: ${(error as Error).message}`);
} finally {
setIsLoading(false);
}
+14 -9
View File
@@ -9,10 +9,12 @@ import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { useAuthStore } from '@store/authStore';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
import type { SpoolPreset } from '@domain/SpoolPreset';
import type { SpoolPreset, CreateSpoolPresetInput } from '@domain/SpoolPreset';
import { createPresetUseCase } from '@infrastructure/container';
const schema = z.object({
name: z.string().min(1, 'Nome obrigatório'),
@@ -40,19 +42,22 @@ export default function NewPresetScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: CreatePresetUseCase via DI container
const preset: SpoolPreset = {
id: `user-${Date.now()}`,
const user = useAuthStore.getState().user;
if (!user) {
throw new Error('Usuário não autenticado');
}
const input: CreateSpoolPresetInput = {
name: data.name,
spoolWeightG: Number(data.spoolWeightG),
isSystem: false,
userId: 'me',
createdAt: new Date().toISOString(),
};
const preset = await createPresetUseCase.execute(user.id, input);
addPreset(preset);
Alert.alert('Sucesso', 'Preset criado com sucesso');
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar o preset.');
} catch (error) {
Alert.alert('Erro', `Não foi possível salvar o preset: ${(error as Error).message}`);
} finally {
setIsLoading(false);
}
+78 -4
View File
@@ -5,12 +5,15 @@ import {
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 { 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 = '50x30' | '62x29' | '38x25';
@@ -44,6 +47,7 @@ export default function LabelScreen(): React.ReactElement {
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']),
);
@@ -68,9 +72,47 @@ export default function LabelScreen(): React.ReactElement {
});
}
function handleExport(): void {
// TODO: Generate SVG and share via expo-sharing
Alert.alert('Exportar SVG', 'Funcionalidade de exportação SVG será implementada na integração com o backend.');
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(',');
try {
if (isPdf) {
const response = await httpClient.get<ArrayBuffer>(
`/filaments/${id}/label.pdf`,
{ params: { width_mm, height_mm, fields }, responseType: 'arraybuffer' },
);
const base64 = btoa(
String.fromCharCode(...new Uint8Array(response.data)),
);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.pdf`;
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
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 }, responseType: 'text' },
);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
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);
@@ -174,8 +216,21 @@ export default function LabelScreen(): React.ReactElement {
{/* 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 SVG"
label={`Exportar ${selectedFormat.toUpperCase()}`}
leftIcon={<Ionicons name="download-outline" size={18} color={colors.bgBase} />}
onPress={handleExport}
/>
@@ -300,8 +355,27 @@ const styles = StyleSheet.create({
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 },
});
+67 -29
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert, Modal, KeyboardAvoidingView, Platform,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
@@ -50,7 +50,8 @@ export default function EditFilamentScreen(): React.ReactElement {
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 [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInputValue, setColorInputValue] = useState('');
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
@@ -86,31 +87,29 @@ export default function EditFilamentScreen(): React.ReactElement {
}
const 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,
);
setColorInputValue(selectedColor);
setIsColorPickerOpen(true);
};
const handleConfirmColor = (): void => {
if (!colorInputValue) {
Alert.alert('Erro', 'Digite um código hexadecimal');
return;
}
const normalized = normalizeHex(colorInputValue.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
setIsColorPickerOpen(false);
setColorInputValue('');
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
};
const handleCancelColor = (): void => {
setIsColorPickerOpen(false);
setColorInputValue('');
};
const handleDelete = async (): Promise<void> => {
@@ -183,7 +182,34 @@ export default function EditFilamentScreen(): React.ReactElement {
}
return (
<SafeAreaView style={styles.safe}>
<>
<Modal visible={isColorPickerOpen} transparent animationType="fade">
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalContent}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Código Hexadecimal</Text>
<TextInput
style={styles.modalInput}
placeholder="#FF5733"
placeholderTextColor={colors.textSecondary}
value={colorInputValue}
onChangeText={setColorInputValue}
autoFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity onPress={handleCancelColor} style={styles.modalBtnCancel}>
<Text style={styles.modalBtnTextCancel}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleConfirmColor} style={styles.modalBtnConfirm}>
<Text style={styles.modalBtnTextConfirm}>Confirmar</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
<SafeAreaView style={styles.safe}>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
@@ -203,7 +229,7 @@ export default function EditFilamentScreen(): React.ReactElement {
<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>
<TouchableOpacity onPress={handleOpenColorPicker}><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
</View>
<View style={styles.colorPalette}>
{PRESET_COLORS.map((c) => (
@@ -371,6 +397,7 @@ export default function EditFilamentScreen(): React.ReactElement {
/>
</View>
</SafeAreaView>
</>
);
}
@@ -421,4 +448,15 @@ const styles = StyleSheet.create({
resultDot: { width: 8, height: 8, borderRadius: radius.full },
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
// Modal styles
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
modalContent: { width: '85%', maxWidth: 320 },
modalCard: { backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[5], gap: spacing[4] },
modalTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
modalInput: { backgroundColor: colors.bgBase, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, paddingHorizontal: spacing[3], paddingVertical: spacing[2], fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
modalButtons: { flexDirection: 'row', gap: spacing[3] },
modalBtnCancel: { flex: 1, backgroundColor: colors.bgHover, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
modalBtnTextCancel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
modalBtnConfirm: { flex: 1, backgroundColor: colors.accent, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
modalBtnTextConfirm: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.bgBase },
});
+70 -31
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert, Modal, KeyboardAvoidingView, Platform,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
@@ -49,7 +49,8 @@ export default function NewFilamentScreen(): React.ReactElement {
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);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInputValue, setColorInputValue] = useState('');
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
useEffect(() => {
@@ -69,33 +70,32 @@ export default function NewFilamentScreen(): React.ReactElement {
? 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,
);
}
const handleOpenColorPicker = (): void => {
setColorInputValue(selectedColor);
setIsColorPickerOpen(true);
};
const handleConfirmColor = (): void => {
if (!colorInputValue) {
Alert.alert('Erro', 'Digite um código hexadecimal');
return;
}
const normalized = normalizeHex(colorInputValue.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
setIsColorPickerOpen(false);
setColorInputValue('');
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
};
const handleCancelColor = (): void => {
setIsColorPickerOpen(false);
setColorInputValue('');
};
async function onSubmit(data: FormData): Promise<void> {
@@ -132,7 +132,34 @@ function handleOpenColorPicker(): void {
}
return (
<SafeAreaView style={styles.safe}> onPress={handleOpenColorPicker}
<>
<Modal visible={isColorPickerOpen} transparent animationType="fade">
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalContent}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Código Hexadecimal</Text>
<TextInput
style={styles.modalInput}
placeholder="#FF5733"
placeholderTextColor={colors.textSecondary}
value={colorInputValue}
onChangeText={setColorInputValue}
autoFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity onPress={handleCancelColor} style={styles.modalBtnCancel}>
<Text style={styles.modalBtnTextCancel}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleConfirmColor} style={styles.modalBtnConfirm}>
<Text style={styles.modalBtnTextConfirm}>Confirmar</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
<SafeAreaView style={styles.safe}>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
@@ -150,7 +177,7 @@ function handleOpenColorPicker(): void {
<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>
<TouchableOpacity onPress={handleOpenColorPicker}><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
</View>
<View style={styles.colorPalette}>
{PRESET_COLORS.map((c) => (
@@ -318,6 +345,7 @@ function handleOpenColorPicker(): void {
/>
</View>
</SafeAreaView>
</>
);
}
@@ -365,4 +393,15 @@ const styles = StyleSheet.create({
resultDot: { width: 8, height: 8, borderRadius: radius.full },
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
// Modal styles
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
modalContent: { width: '85%', maxWidth: 320 },
modalCard: { backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[5], gap: spacing[4] },
modalTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
modalInput: { backgroundColor: colors.bgBase, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, paddingHorizontal: spacing[3], paddingVertical: spacing[2], fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
modalButtons: { flexDirection: 'row', gap: spacing[3] },
modalBtnCancel: { flex: 1, backgroundColor: colors.bgHover, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
modalBtnTextCancel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
modalBtnConfirm: { flex: 1, backgroundColor: colors.accent, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
modalBtnTextConfirm: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.bgBase },
});
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200" height="200" style="width: 38px; height: 38px; flex-shrink: 0">
<g stroke="#C9C1B0" stroke-width="10" fill="none">
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49" />
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80" />
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11" />
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 432 B

+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Carregar variáveis do .env
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
echo "Building Android Release APK with mise..."
echo "API URL: $EXPO_PUBLIC_API_URL"
mise exec -- npx expo prebuild --clean
# Exportar variáveis para o processo Gradle
export EXPO_PUBLIC_API_URL="${EXPO_PUBLIC_API_URL:-https://meowspool.felipecncloud.com/api/v1}"
export EXPO_PUBLIC_GOOGLE_CLIENT_ID="${EXPO_PUBLIC_GOOGLE_CLIENT_ID}"
export EXPO_PUBLIC_APP_SCHEME="${EXPO_PUBLIC_APP_SCHEME:-meowspool}"
export EXPO_PUBLIC_APP_ENV="${EXPO_PUBLIC_APP_ENV:-production}"
mise exec -- ./android/gradlew -p android assembleRelease
echo "Build complete!"
echo "APK: android/app/build/outputs/apk/release/app-release.apk"
+14 -30
View File
@@ -17,10 +17,12 @@
"expo": "~54.0.0",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
@@ -34,6 +36,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-worklets": "^0.7.4",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
@@ -1405,7 +1408,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -3870,7 +3872,7 @@
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -5593,7 +5595,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -6793,6 +6795,15 @@
"node": ">=20.16.0"
}
},
"node_modules/expo-sharing": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-splash-screen": {
"version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
@@ -10826,26 +10837,6 @@
"ws": "^7"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@@ -11057,7 +11048,6 @@
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.4.tgz",
"integrity": "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "7.27.1",
"@babel/plugin-transform-class-properties": "7.27.1",
@@ -11082,7 +11072,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
"integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
@@ -11099,7 +11088,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
"integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-compilation-targets": "^7.27.2",
@@ -11120,7 +11108,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
"integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -11136,7 +11123,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
"integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
@@ -11153,7 +11139,6 @@
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
@@ -11173,7 +11158,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
+3
View File
@@ -20,10 +20,12 @@
"expo": "~54.0.0",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
@@ -37,6 +39,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-worklets": "^0.7.4",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},