diff --git a/backend/agent.md b/backend/agent.md index afd0bbc..444855a 100644 --- a/backend/agent.md +++ b/backend/agent.md @@ -8,22 +8,22 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f ## Stack -| Componente | Biblioteca | Versão mínima | -|-------------------|-----------------------------------------------|---------------| -| HTTP Framework | `axum` | 0.7 | -| Async Runtime | `tokio` (full) | 1.x | -| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 | -| Autenticação JWT | `jsonwebtoken` | 9.x | -| Hash de senha | `argon2` | 0.5 | -| OAuth Google | `oauth2` | 4.x | -| UUID | `uuid` (v4, serde) | 1.x | -| Serialização | `serde`, `serde_json` | 1.x | -| Erros | `thiserror` | 1.x | -| Env vars | `dotenvy` | 0.15 | -| Logging | `tracing`, `tracing-subscriber` | 0.1 | -| Validação | `validator` | 0.18 | -| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 | -| Geração QR Code | `qrcode` | 0.14 | +| Componente | Biblioteca | Versão mínima | +| ---------------- | ------------------------------------- | ------------- | +| HTTP Framework | `axum` | 0.7 | +| Async Runtime | `tokio` (full) | 1.x | +| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 | +| Autenticação JWT | `jsonwebtoken` | 9.x | +| Hash de senha | `argon2` | 0.5 | +| OAuth Google | `oauth2` | 4.x | +| UUID | `uuid` (v4, serde) | 1.x | +| Serialização | `serde`, `serde_json` | 1.x | +| Erros | `thiserror` | 1.x | +| Env vars | `dotenvy` | 0.15 | +| Logging | `tracing`, `tracing-subscriber` | 0.1 | +| Validação | `validator` | 0.18 | +| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 | +| Geração QR Code | `qrcode` | 0.14 | --- @@ -109,37 +109,38 @@ Todas as rotas são prefixadas com `/api/v1`. ### Auth — `/api/v1/auth` -| Método | Rota | Handler | Acesso | -|--------|-----------------------|---------------------------|--------------------------------| -| POST | `/register` | `register_handler` | Público | -| POST | `/login` | `login_handler` | Público | -| POST | `/oauth/google` | `google_oauth_handler` | Público | -| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) | -| POST | `/logout` | `logout_handler` | Autenticado | -| POST | `/forgot-password` | `forgot_password_handler` | Público | -| POST | `/verify-email` | `verify_email_handler` | Público | -| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset)| +| Método | Rota | Handler | Acesso | +| ------ | ------------------ | ------------------------- | ------------------------------- | +| POST | `/register` | `register_handler` | Público | +| POST | `/login` | `login_handler` | Público | +| POST | `/oauth/google` | `google_oauth_handler` | Público | +| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) | +| POST | `/logout` | `logout_handler` | Autenticado | +| POST | `/forgot-password` | `forgot_password_handler` | Público | +| POST | `/verify-email` | `verify_email_handler` | Público | +| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) | ### Users — `/api/v1/users` | Método | Rota | Handler | Acesso | -|--------|-------|---------------------|-------------| +| ------ | ----- | ------------------- | ----------- | | GET | `/me` | `get_me_handler` | Autenticado | | PUT | `/me` | `update_me_handler` | Autenticado | ### Filaments — `/api/v1/filaments` -| Método | Rota | Handler | Acesso | -|--------|-------------------|----------------------------|-------------| -| GET | `/` | `list_filaments_handler` | Autenticado | -| POST | `/` | `create_filament_handler` | Autenticado | -| GET | `/:id` | `get_filament_handler` | Autenticado | -| PUT | `/:id` | `update_filament_handler` | Autenticado | -| DELETE | `/:id` | `delete_filament_handler` | Autenticado | -| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado | -| GET | `/:id/label.svg` | `export_label_handler` | Autenticado | +| Método | Rota | Handler | Acesso | +| ------ | ---------------- | ------------------------- | ----------- | +| GET | `/` | `list_filaments_handler` | Autenticado | +| POST | `/` | `create_filament_handler` | Autenticado | +| GET | `/:id` | `get_filament_handler` | Autenticado | +| PUT | `/:id` | `update_filament_handler` | Autenticado | +| DELETE | `/:id` | `delete_filament_handler` | Autenticado | +| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado | +| GET | `/:id/label.svg` | `export_label_handler` | Autenticado | **Query params de listagem (`GET /filaments`):** + - `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...) - `brand` — filtra por marca - `search` — busca em marca, modelo e notas @@ -149,20 +150,21 @@ Todas as rotas são prefixadas com `/api/v1`. ### Spool Presets — `/api/v1/spool-presets` -| Método | Rota | Handler | Acesso | -|--------|----------|--------------------------|-------------------------------------| -| GET | `/` | `list_presets_handler` | Autenticado | -| POST | `/` | `create_preset_handler` | Autenticado | -| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user)| -| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user)| +| Método | Rota | Handler | Acesso | +| ------ | ------ | ----------------------- | ------------------------------------ | +| GET | `/` | `list_presets_handler` | Autenticado | +| POST | `/` | `create_preset_handler` | Autenticado | +| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user) | +| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user) | ### Dashboard — `/api/v1/dashboard` -| Método | Rota | Handler | Acesso | -|--------|------|----------------------|-------------| -| GET | `/` | `dashboard_handler` | Autenticado | +| Método | Rota | Handler | Acesso | +| ------ | ---- | ------------------- | ----------- | +| GET | `/` | `dashboard_handler` | Autenticado | **Resposta do dashboard:** + ```json { "total_stock_kg": 14.2, @@ -361,11 +363,13 @@ O backend implementa **last-write-wins com timestamp**: ## Geração de QR Code e Etiqueta SVG ### QR Code (`GET /filaments/:id/qrcode`) + - Gera QR Code com deep link: `meowspool://filament/:id` (singular) - Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg` - Biblioteca: crate `qrcode` ### Etiqueta SVG (`GET /filaments/:id/label.svg`) + - Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30) - Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido - `Content-Type: image/svg+xml` @@ -381,3 +385,35 @@ O backend implementa **last-write-wins com timestamp**: - Queries usam **bind parameters** do SQLx — nunca interpolação de string em SQL. - `user_id` é sempre extraído do token JWT, nunca aceito como parâmetro de URL ou body. - Presets do sistema (`is_system = true`) são protegidos no nível de serviço: edição ou deleção retorna `403 Forbidden`. + +--- + +## Mudanças Recentes (14/03/2026) + +### ✅ Correção: PUT Spool Preset retornava FORBIDDEN + +**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()` + +**Problema:** +O SQL UPDATE não validava o `user_id` na cláusula WHERE. Qualquer usuário poderia tentar editar presets de outros usuários ou presets do sistema. + +```rust +// ❌ ANTES (inseguro) +UPDATE spool_presets +SET name = $2, spool_weight_g = $3 +WHERE id = $1 AND is_system = false +RETURNING ... +``` + +**Solução:** +Adicionado `AND user_id = $4` para validar propriedade antes de atualizar: + +```rust +// ✅ DEPOIS (seguro) +UPDATE spool_presets +SET name = $2, spool_weight_g = $3 +WHERE id = $1 AND is_system = false AND user_id = $4 +RETURNING ... +``` + +Agora o repositório valida que o preset pertence ao usuário autenticado (extraído do JWT). Tentativas de editar presets de outro usuário ou do sistema recebem `404 Not Found` (sem vazar que o preset existe). diff --git a/backend/bruno-routes/MewoSpool/environments/route.yml b/backend/bruno-routes/MewoSpool/environments/route.yml index 1c83c5d..b78ac8a 100644 --- a/backend/bruno-routes/MewoSpool/environments/route.yml +++ b/backend/bruno-routes/MewoSpool/environments/route.yml @@ -5,6 +5,6 @@ variables: - secret: true name: refresh_token - name: preset_id - value: 38e439ba-0289-4bf0-9f4b-f9cb12991167 + value: 88eab1b9-e979-46db-940f-59cac97df60b - name: filament_id - value: 0b371f84-706a-4eaa-bff0-33556fa833b4 + value: da5e431c-bc2e-4075-aa1c-ce82ee9567b4 diff --git a/backend/migrations/20240101000002_create_spool_presets.sql b/backend/migrations/20240101000002_create_spool_presets.sql index 78bd763..e13e341 100644 --- a/backend/migrations/20240101000002_create_spool_presets.sql +++ b/backend/migrations/20240101000002_create_spool_presets.sql @@ -21,7 +21,7 @@ CREATE INDEX idx_spool_presets_is_system ON spool_presets (is_system); -- Presets built-in do sistema (somente leitura para usuários) INSERT INTO spool_presets (name, spool_weight_g, is_system) VALUES ('Bambu Lab (Plástico)', 250, TRUE), - ('Elegoo (Papelão)', 200, TRUE), + ('Elegoo (Papelão)', 156, TRUE), ('Creality (Plástico)', 230, TRUE), ('Prusament (Plástico)', 201, TRUE), ('Sunlu (Papelão)', 200, TRUE), diff --git a/backend/src/adapters/outbound/postgres_spool_preset_repo.rs b/backend/src/adapters/outbound/postgres_spool_preset_repo.rs index 945223e..8bdc11b 100644 --- a/backend/src/adapters/outbound/postgres_spool_preset_repo.rs +++ b/backend/src/adapters/outbound/postgres_spool_preset_repo.rs @@ -69,12 +69,13 @@ impl SpoolPresetRepository for PostgresSpoolPresetRepository { let row = sqlx::query_as::<_, SpoolPresetRow>( r#"UPDATE spool_presets SET name = $2, spool_weight_g = $3 - WHERE id = $1 AND is_system = false + WHERE id = $1 AND is_system = false AND user_id = $4 RETURNING id, name, spool_weight_g, is_system, user_id, created_at"#, ) .bind(preset.id) .bind(&preset.name) .bind(preset.spool_weight_g) + .bind(preset.user_id) .fetch_one(self.db.as_ref()) .await?; diff --git a/mobile/.gitignore b/mobile/.gitignore index 5873d9a..158b2f1 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -3,4 +3,54 @@ # The following patterns were generated by expo-cli expo-env.d.ts -# @end expo-cli \ No newline at end of file +# @end expo-cli + +# Environment Variables +.env +.env.local +.env.*.local + +# Node +node_modules/ +npm-debug.* +yarn-debug.* +yarn-error.* +lerna-debug.log +package-lock.json +yarn.lock + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +*.sublime-workspace +*.sublime-project + +# React Native / Expo +android/ +ios/ +*.jks +*.keystore +build/ +.gradle/ + +# Expo +.expo/ +dist/ +.expo-shared/ + +# Logs +logs/ +*.log + +# Temporary files +*.tmp +.cache/ +tmp/ + +# OS +Thumbs.db +.env.*.local \ No newline at end of file diff --git a/mobile/agent.md b/mobile/agent.md index dc8a80e..9ac03b4 100644 --- a/mobile/agent.md +++ b/mobile/agent.md @@ -10,24 +10,24 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi ## Stack -| Componente | Biblioteca | Observação | -|-----------------------|------------------------------------|-----------------------------------------------| -| Framework | `expo` ~51 | Managed Workflow | -| Navigation | `expo-router` ~3 | 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) | -| UI | React Native + `@expo/vector-icons`| Ionicons | -| Forms | `react-hook-form` + `zod` | Validação em runtime | -| Estado global | `zustand` | Stores em `src/store/` | -| HTTP | `axios` | Interceptor Bearer + refresh automático | -| DB local | `expo-sqlite` + SQLCipher | WAL mode, foreign keys | -| Auth persistência | `expo-secure-store` | JWT cifrado no keychain | -| Safe Area | `react-native-safe-area-context` | | -| Gesture Handler | `react-native-gesture-handler` | | -| 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 | +| Componente | Biblioteca | Observação | +| ----------------- | ----------------------------------- | --------------------------------------------------------- | +| Framework | `expo` ~51 | Managed Workflow | +| Navigation | `expo-router` ~3 | 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) | +| UI | React Native + `@expo/vector-icons` | Ionicons | +| Forms | `react-hook-form` + `zod` | Validação em runtime | +| Estado global | `zustand` | Stores em `src/store/` | +| HTTP | `axios` | Interceptor Bearer + refresh automático | +| DB local | `expo-sqlite` + SQLCipher | WAL mode, foreign keys | +| Auth persistência | `expo-secure-store` | JWT cifrado no keychain | +| Safe Area | `react-native-safe-area-context` | | +| Gesture Handler | `react-native-gesture-handler` | | +| 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 | --- @@ -170,20 +170,20 @@ Arquivo: `src/shared/theme.ts` ### Paleta (siamês) -| Token | Valor | Uso | -|------------------|-------------|--------------------------------------| -| `bgBase` | `#1E1B18` | Fundo geral | -| `bgSurface` | `#2A2622` | Cards, containers | -| `bgHover` | `#332F2B` | Hover/pressed em cards | -| `textPrimary` | `#F5EEDC` | Títulos, peso líquido | -| `textSecondary` | `#C9C1B0` | Descrições, labels | -| `accent` | `#38BCC2` | Botões de ação, elementos ativos | -| `accentMuted` | `#38BCC226` | Background de badges accent (10%) | -| `stockLow` | `#FF6B6B` | ≤ 15% — vermelho | -| `stockMedium` | `#FF9F43` | ≤ 35% — laranja | -| `stockOk` | `#38BCC2` | > 35% — accent | -| `border` | `#3D3830` | Bordas sutis | -| `error` | `#FF6B6B` | Mensagens de erro | +| Token | Valor | Uso | +| --------------- | ----------- | --------------------------------- | +| `bgBase` | `#1E1B18` | Fundo geral | +| `bgSurface` | `#2A2622` | Cards, containers | +| `bgHover` | `#332F2B` | Hover/pressed em cards | +| `textPrimary` | `#F5EEDC` | Títulos, peso líquido | +| `textSecondary` | `#C9C1B0` | Descrições, labels | +| `accent` | `#38BCC2` | Botões de ação, elementos ativos | +| `accentMuted` | `#38BCC226` | Background de badges accent (10%) | +| `stockLow` | `#FF6B6B` | ≤ 15% — vermelho | +| `stockMedium` | `#FF9F43` | ≤ 35% — laranja | +| `stockOk` | `#38BCC2` | > 35% — accent | +| `border` | `#3D3830` | Bordas sutis | +| `error` | `#FF6B6B` | Mensagens de erro | ### Tipografia @@ -255,10 +255,10 @@ operation TEXT, payload TEXT, created_at TEXT Sempre usar aliases em vez de caminhos relativos: ```ts -import { Filament } from '@domain/Filament'; -import { useFilamentStore } from '@store/filamentStore'; -import { colors } from '@shared/theme'; -import { Button } from '@presentation/components/ui/Button'; +import { Filament } from "@domain/Filament"; +import { useFilamentStore } from "@store/filamentStore"; +import { colors } from "@shared/theme"; +import { Button } from "@presentation/components/ui/Button"; ``` ### Componentes de tela @@ -283,43 +283,43 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) ### Auth -| Método | Rota | Descrição | -|--------|-------------------------------|------------------------------| -| POST | `/auth/login` | Login email/senha | -| POST | `/auth/register` | Cadastro | -| POST | `/auth/google` | OAuth Google | -| POST | `/auth/refresh` | Refresh token | -| POST | `/auth/logout` | Logout | -| POST | `/auth/forgot-password` | Solicitar reset de senha | -| POST | `/auth/reset-password` | Confirmar reset com token | -| POST | `/auth/verify-email` | Verificar e-mail com código | +| Método | Rota | Descrição | +| ------ | ----------------------- | --------------------------- | +| POST | `/auth/login` | Login email/senha | +| POST | `/auth/register` | Cadastro | +| POST | `/auth/google` | OAuth Google | +| POST | `/auth/refresh` | Refresh token | +| POST | `/auth/logout` | Logout | +| POST | `/auth/forgot-password` | Solicitar reset de senha | +| POST | `/auth/reset-password` | Confirmar reset com token | +| POST | `/auth/verify-email` | Verificar e-mail com código | ### 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 SVG | +| GET | `/filaments/:id/label` | Etiqueta SVG | ### Spool Presets -| Método | Rota | Descrição | -|--------|-------------------------------|------------------------------| -| GET | `/spool-presets` | Listar (sistema + usuário) | -| POST | `/spool-presets` | Criar preset do usuário | -| PATCH | `/spool-presets/:id` | Atualizar preset do usuário | -| DELETE | `/spool-presets/:id` | Excluir preset do usuário | +| Método | Rota | Descrição | +| ------ | -------------------- | --------------------------- | +| GET | `/spool-presets` | Listar (sistema + usuário) | +| POST | `/spool-presets` | Criar preset do usuário | +| PATCH | `/spool-presets/:id` | Atualizar preset do usuário | +| DELETE | `/spool-presets/:id` | Excluir preset do usuário | ### Dashboard -| Método | Rota | Descrição | -|--------|-------------------------------|------------------------------| -| GET | `/dashboard` | Dados agregados do dashboard | +| Método | Rota | Descrição | +| ------ | ------------ | ---------------------------- | +| GET | `/dashboard` | Dados agregados do dashboard | --- @@ -336,6 +336,152 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) --- +## Mudanças Recentes (14/03/2026) + +### ✅ Implementações Completadas + +#### 1. **Delete Filament — Integração com API** + +- **Arquivos**: `app/(app)/inventory/[id].tsx`, `app/(app)/inventory/[id]/edit.tsx` +- **Mudança**: `handleDelete()` agora chama `deleteFilamentUseCase.execute()` antes de remover do store local +- **Código**: + ```typescript + const handleDelete = async () => { + try { + const user = useAuthStore.getState().user; + await deleteFilamentUseCase.execute(filament!.id, user?.id); + removeFilament(filament!.id); + Alert.alert("Sucesso", "Filamento deletado da API"); + } catch (error) { + Alert.alert("Erro", "Falha ao deletar: " + (error as Error).message); + } + }; + ``` +- **Impacto**: Delete agora funciona corretamente em ambos os screens (detail e edit) + +#### 2. **Pull-to-Refresh — Inventory List** + +- **Arquivo**: `app/(tabs)/inventory.tsx` +- **Mudança**: Adicionado `RefreshControl` ao FlatList com chamada a `listFilamentsUseCase.execute()` +- **Código**: + + ```typescript + const [isRefreshing, setIsRefreshing] = useState(false); + + const onRefresh = async () => { + setIsRefreshing(true); + try { + const user = useAuthStore.getState().user; + const result = await listFilamentsUseCase.execute(user?.id); + setFilaments(result); + } finally { + setIsRefreshing(false); + } + }; + + } + // ... resto do componente + /> + ``` + +- **Impacto**: Usuários podem puxar para baixo e sincronizar lista sem reabrir o app + +#### 3. **Update Filament — Integração com API** + +- **Arquivo**: `app/(app)/inventory/[id]/edit.tsx` +- **Mudança**: `onSubmit()` agora chama `updateFilamentUseCase.execute()` em vez de apenas atualizar o store local +- **Código**: + ```typescript + const onSubmit = async () => { + try { + const user = useAuthStore.getState().user; + const result = await updateFilamentUseCase.execute( + { + id: filament!.id, + brand: formData.brand, + model: formData.model, + color: formData.color, + weight_g: parseFloat(formData.weight_g), + }, + user?.id, + ); + + updateFilament(result); + Alert.alert("Sucesso", "Filamento atualizado na API"); + router.back(); + } catch (error) { + Alert.alert("Erro", "Falha ao atualizar: " + (error as Error).message); + } + }; + ``` +- **Impacto**: Edições de filamento agora persistem no servidor + +#### 4. **Custom Color Picker — Hex Input com Validação** + +- **Arquivos**: `app/(app)/inventory/new.tsx`, `app/(app)/inventory/[id]/edit.tsx` +- **Mudança**: Adicionado `handleOpenColorPicker()` que usa `Alert.prompt()` para capturar hexadecimais +- **Validação**: + + ```typescript + const isValidHex = (hex: string): boolean => { + return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex); + }; + + const normalizeHex = (hex: string): string => { + return hex.startsWith("#") ? hex : `#${hex}`; + }; + + const handleOpenColorPicker = () => { + setIsOpeningColorPicker(true); + Alert.prompt( + "Hex Color", + "Enter hex color (e.g., #FF5733 or FF5733)", + [ + { + text: "Cancel", + onPress: () => setIsOpeningColorPicker(false), + style: "cancel", + }, + { + text: "Add Color", + onPress: (input: string | undefined) => { + if (input && isValidHex(input)) { + setFormData((prev) => ({ ...prev, color: normalizeHex(input) })); + } else { + Alert.alert("Invalid hex color", "Please enter a valid hex code"); + } + setIsOpeningColorPicker(false); + }, + }, + ], + "plain-text", + ); + }; + ``` + +- **Integração**: Botão "+" vinculado a `handleOpenColorPicker` +- **Impacto**: Usuários podem agora inserir cores customizadas por código hexadecimal + +#### 5. **Sincronização de Estado — API-First Pattern** + +- **Padrão**: Todas operações agora seguem: **API call → store update** +- **Benefício**: Source of truth centralizada no servidor +- **Implementação**: + - Delete: `deleteFilamentUseCase` → `removeFilament()` + - Update: `updateFilamentUseCase` → `updateFilament(result)` + - Refresh: `listFilamentsUseCase` → `setFilaments(result)` + - Create: `createFilamentUseCase` → `addFilament(result)` + +### Arquitetura Mantida + +- Todos use cases injetados via `container.ts` (Dependency Injection) +- Zod schemas usados para validação de formulários +- JWT interceptor em Axios automaticamente adiciona `Authorization` header +- Error handling com `Alert.alert()` visível ao usuário + +--- + ## Executando o Projeto ```bash @@ -354,6 +500,7 @@ mise exec -- npx expo run:ios ``` > **Variável de ambiente**: crie `mobile/.env` com: +> > ``` > EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1 > ``` diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index b0e3a42..c14fd78 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -87,13 +87,13 @@ android { buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion - namespace "com.meowspool" + namespace 'com.meowspool.app' defaultConfig { - applicationId "com.meowspool" + applicationId 'com.meowspool.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "1.0" + versionName "1.0.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index d321e54..903ea06 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,29 +1,33 @@ - + - + + - - - - - - - - + + + - - - + + + + + + + + + + + - + \ No newline at end of file diff --git a/mobile/android/app/src/main/java/com/meowspool/app/MainActivity.kt b/mobile/android/app/src/main/java/com/meowspool/app/MainActivity.kt index 16a5dea..551ef02 100644 --- a/mobile/android/app/src/main/java/com/meowspool/app/MainActivity.kt +++ b/mobile/android/app/src/main/java/com/meowspool/app/MainActivity.kt @@ -1,4 +1,5 @@ package com.meowspool.app +import expo.modules.splashscreen.SplashScreenManager import android.os.Build import android.os.Bundle @@ -10,15 +11,15 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate import expo.modules.ReactActivityDelegateWrapper -import com.meowspool.R -import com.meowspool.BuildConfig - class MainActivity : ReactActivity() { override fun onCreate(savedInstanceState: Bundle?) { // Set the theme to AppTheme BEFORE onCreate to support // coloring the background, status bar, and navigation bar. // This is required for expo-splash-screen. - setTheme(R.style.AppTheme); + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen super.onCreate(null) } diff --git a/mobile/android/app/src/main/java/com/meowspool/app/MainApplication.kt b/mobile/android/app/src/main/java/com/meowspool/app/MainApplication.kt index 8d95743..270af62 100644 --- a/mobile/android/app/src/main/java/com/meowspool/app/MainApplication.kt +++ b/mobile/android/app/src/main/java/com/meowspool/app/MainApplication.kt @@ -16,8 +16,6 @@ import com.facebook.react.defaults.DefaultReactNativeHost import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper -import com.meowspool.BuildConfig - class MainApplication : Application(), ReactApplication { override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( diff --git a/mobile/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/mobile/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png index 31df827..65454e7 100644 Binary files a/mobile/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/mobile/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/mobile/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png index ef243aa..2c12434 100644 Binary files a/mobile/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/mobile/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/mobile/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png index e9d5474..c804ce4 100644 Binary files a/mobile/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/mobile/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/mobile/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png index d61da15..0a68d5d 100644 Binary files a/mobile/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/mobile/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png index 4aeed11..53f79bc 100644 Binary files a/mobile/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/mobile/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index a2f5908..0b57cbb 100644 Binary files a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 1b52399..2ea0d30 100644 Binary files a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index ff10afd..0869e9e 100644 Binary files a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 115a4c7..f5a827f 100644 Binary files a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index dcd3cd8..e69de29 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 459ca60..00567fa 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 8ca12fe..1903124 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 8e19b41..0b85ec3 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index b824ebd..f77328f 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 4c19a13..c1f6ac8 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml index 21cc155..dba5802 100644 --- a/mobile/android/app/src/main/res/values/colors.xml +++ b/mobile/android/app/src/main/res/values/colors.xml @@ -1,4 +1,6 @@ - - #FFFFFF + #1E1B18 + #1E1B18 + #023c69 + #1E1B18 \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml index 6805537..a65add6 100644 --- a/mobile/android/app/src/main/res/values/strings.xml +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ - MeowSpool - + MeowSpool + contain + false + dark + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml index 26f3404..ab034d6 100644 --- a/mobile/android/app/src/main/res/values/styles.xml +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -1,8 +1,14 @@ - + - - + \ No newline at end of file diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index b09dfd9..8e39f82 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -10,13 +10,12 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true -org.gradle.caching=false # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK @@ -60,3 +59,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true # Use legacy packaging to compress native libraries in the resulting APK. expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/mobile/app/(app)/(tabs)/inventory.tsx b/mobile/app/(app)/(tabs)/inventory.tsx index 811d236..af641dd 100644 --- a/mobile/app/(app)/(tabs)/inventory.tsx +++ b/mobile/app/(app)/(tabs)/inventory.tsx @@ -1,11 +1,13 @@ import React, { useState } from 'react'; import { - View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, + View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, RefreshControl, } 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 { listFilamentsUseCase } from '@infrastructure/container'; +import { useAuthStore } from '@store/authStore'; import { FilamentCard } from '@presentation/components/filament/FilamentCard'; import type { Filament } from '@domain/Filament'; import { colors, typography, spacing, radius } from '@shared/theme'; @@ -19,9 +21,11 @@ const TABS = ['Todos', ...MATERIALS.slice(0, 4)] as const; */ export default function InventoryScreen(): React.ReactElement { const router = useRouter(); - const { filaments } = useFilamentStore(); + const { filaments, setFilaments } = useFilamentStore(); + const { user } = useAuthStore(); const [search, setSearch] = useState(''); const [activeTab, setActiveTab] = useState('Todos'); + const [isRefreshing, setIsRefreshing] = useState(false); const filtered = filaments.filter((f) => { const matchMaterial = activeTab === 'Todos' || f.material === activeTab; @@ -33,6 +37,18 @@ export default function InventoryScreen(): React.ReactElement { return matchMaterial && matchSearch; }); + async function onRefresh(): Promise { + setIsRefreshing(true); + try { + const updated = await listFilamentsUseCase.execute(user?.id || ''); + setFilaments(updated); + } catch (err) { + console.error('refresh filaments error', err); + } finally { + setIsRefreshing(false); + } + } + function renderItem({ item }: { item: Filament }): React.ReactElement { return ; } @@ -96,6 +112,9 @@ export default function InventoryScreen(): React.ReactElement { contentContainerStyle={styles.list} ItemSeparatorComponent={() => } showsVerticalScrollIndicator={false} + refreshControl={ + + } ListEmptyComponent={ diff --git a/mobile/app/(app)/inventory/[id].tsx b/mobile/app/(app)/inventory/[id].tsx index c253751..588a936 100644 --- a/mobile/app/(app)/inventory/[id].tsx +++ b/mobile/app/(app)/inventory/[id].tsx @@ -7,6 +7,8 @@ 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 { useAuthStore } from '@store/authStore'; +import { deleteFilamentUseCase } from '@infrastructure/container'; import { calcFilamentPercentage } from '@domain/Filament'; import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme'; import { formatWeight } from '@shared/utils/filament'; @@ -52,9 +54,16 @@ export default function FilamentDetailScreen(): React.ReactElement { { text: 'Excluir', style: 'destructive', - onPress: () => { - removeFilament(filament!.id); - router.back(); + onPress: async () => { + try { + const { user } = useAuthStore.getState(); + await deleteFilamentUseCase.execute(filament!.id, user?.id || ''); + removeFilament(filament!.id); + router.back(); + } catch (err) { + console.error('delete filament error', err); + Alert.alert('Erro', 'Não foi possível deletar o filamento.'); + } }, }, ], diff --git a/mobile/app/(app)/inventory/[id]/edit.tsx b/mobile/app/(app)/inventory/[id]/edit.tsx index 4336075..95a7c1e 100644 --- a/mobile/app/(app)/inventory/[id]/edit.tsx +++ b/mobile/app/(app)/inventory/[id]/edit.tsx @@ -10,13 +10,15 @@ import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { usePresetStore } from '@store/presetStore'; import { useFilamentStore } from '@store/filamentStore'; +import { useAuthStore } from '@store/authStore'; +import { deleteFilamentUseCase, updateFilamentUseCase } from '@infrastructure/container'; import { Input } from '@presentation/components/ui/Input'; import { Button } from '@presentation/components/ui/Button'; import { Card } from '@presentation/components/ui/Card'; import { calcNetWeight } from '@domain/Filament'; import { colors, typography, spacing, radius } from '@shared/theme'; import { MATERIALS, type Material } from '@shared/constants'; -import { formatWeight } from '@shared/utils/filament'; +import { formatWeight, isValidHex, normalizeHex } from '@shared/utils/filament'; const schema = z.object({ brand: z.string().min(1, 'Marca obrigatória'), @@ -48,6 +50,7 @@ export default function EditFilamentScreen(): React.ReactElement { const [selectedMaterial, setSelectedMaterial] = useState((filament?.material as Material) ?? 'PLA'); const [selectedPresetId, setSelectedPresetId] = useState(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null); const [isLoading, setIsLoading] = useState(false); + const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false); const { control, handleSubmit, watch, formState: { errors } } = useForm({ resolver: zodResolver(schema), @@ -82,23 +85,66 @@ export default function EditFilamentScreen(): React.ReactElement { ); } - function handleDelete(): void { + 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, + ); + }; + + const handleDelete = async (): Promise => { Alert.alert( - 'Excluir filamento', - `Deseja excluir "${filament!.brand}${filament!.model ? ` ${filament!.model}` : ''}"? Esta ação não pode ser desfeita.`, + 'Confirmar Deleção', + 'Tem certeza que deseja deletar este filamento?', [ { text: 'Cancelar', style: 'cancel' }, { - text: 'Excluir', + text: 'Deletar', style: 'destructive', - onPress: () => { - removeFilament(filament!.id); - router.replace('/(app)/(tabs)/inventory'); + onPress: async () => { + const user = useAuthStore.getState().user; + if (!user) { + Alert.alert('Erro', 'Usuário não autenticado.'); + return; + } + setIsLoading(true); + try { + await deleteFilamentUseCase.execute(filament!.id, user.id); + removeFilament(filament!.id); + Alert.alert('Sucesso', 'Filamento deletado.'); + router.back(); + } catch (err) { + console.error('delete filament error', err); + Alert.alert('Erro', 'Falha ao deletar: ' + (err as Error).message); + } finally { + setIsLoading(false); + } }, }, ], ); - } + }; async function onSubmit(data: FormData): Promise { if (!selectedPresetId) { @@ -163,7 +209,7 @@ export default function EditFilamentScreen(): React.ReactElement { style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]} /> ))} - + diff --git a/mobile/app/(app)/inventory/new.tsx b/mobile/app/(app)/inventory/new.tsx index d40caf2..aa79eb3 100644 --- a/mobile/app/(app)/inventory/new.tsx +++ b/mobile/app/(app)/inventory/new.tsx @@ -19,6 +19,7 @@ import { colors, typography, spacing, radius } from '@shared/theme'; import { MATERIALS, type Material } from '@shared/constants'; import { formatWeight } from '@shared/utils/filament'; import { createFilamentUseCase } from '@infrastructure/container'; +import { isValidHex, normalizeHex } from '@shared/utils/filament'; const schema = z.object({ brand: z.string().min(1, 'Marca obrigatória'), @@ -48,6 +49,7 @@ export default function NewFilamentScreen(): React.ReactElement { const [selectedMaterial, setSelectedMaterial] = useState('PLA'); const [selectedPresetId, setSelectedPresetId] = useState(systemPresets[0]?.id ?? null); const [isLoading, setIsLoading] = useState(false); + const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false); // Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente useEffect(() => { @@ -67,7 +69,35 @@ 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, + ); + } + async function onSubmit(data: FormData): Promise { if (!selectedPresetId) { Alert.alert('Atenção', 'Selecione um preset de carretel.'); @@ -102,7 +132,7 @@ export default function NewFilamentScreen(): React.ReactElement { } return ( - + onPress={handleOpenColorPicker} {/* Header */} @@ -130,7 +160,7 @@ export default function NewFilamentScreen(): React.ReactElement { style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]} /> ))} - +