feat: enhance filament management and UI improvements
- Update SpoolPresetRepository to include user_id in the update query. - Expand .gitignore to include various environment and temporary files. - Revise agent documentation for better clarity and formatting. - Implement pull-to-refresh functionality in the inventory list. - Integrate API calls for deleting and updating filaments, ensuring state synchronization. - Add custom color picker for filament color selection with hex validation. - Update AndroidManifest and Gradle files for improved configuration and permissions. - Refactor MainActivity and MainApplication for better splash screen handling. - Update styles and colors for a cohesive UI experience. - Replace splash screen logos and icons with new assets.
@@ -8,22 +8,22 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
|
|||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Componente | Biblioteca | Versão mínima |
|
| Componente | Biblioteca | Versão mínima |
|
||||||
|-------------------|-----------------------------------------------|---------------|
|
| ---------------- | ------------------------------------- | ------------- |
|
||||||
| HTTP Framework | `axum` | 0.7 |
|
| HTTP Framework | `axum` | 0.7 |
|
||||||
| Async Runtime | `tokio` (full) | 1.x |
|
| Async Runtime | `tokio` (full) | 1.x |
|
||||||
| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 |
|
| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 |
|
||||||
| Autenticação JWT | `jsonwebtoken` | 9.x |
|
| Autenticação JWT | `jsonwebtoken` | 9.x |
|
||||||
| Hash de senha | `argon2` | 0.5 |
|
| Hash de senha | `argon2` | 0.5 |
|
||||||
| OAuth Google | `oauth2` | 4.x |
|
| OAuth Google | `oauth2` | 4.x |
|
||||||
| UUID | `uuid` (v4, serde) | 1.x |
|
| UUID | `uuid` (v4, serde) | 1.x |
|
||||||
| Serialização | `serde`, `serde_json` | 1.x |
|
| Serialização | `serde`, `serde_json` | 1.x |
|
||||||
| Erros | `thiserror` | 1.x |
|
| Erros | `thiserror` | 1.x |
|
||||||
| Env vars | `dotenvy` | 0.15 |
|
| Env vars | `dotenvy` | 0.15 |
|
||||||
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
|
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
|
||||||
| Validação | `validator` | 0.18 |
|
| Validação | `validator` | 0.18 |
|
||||||
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
|
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
|
||||||
| Geração QR Code | `qrcode` | 0.14 |
|
| Geração QR Code | `qrcode` | 0.14 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,37 +109,38 @@ Todas as rotas são prefixadas com `/api/v1`.
|
|||||||
|
|
||||||
### Auth — `/api/v1/auth`
|
### Auth — `/api/v1/auth`
|
||||||
|
|
||||||
| Método | Rota | Handler | Acesso |
|
| Método | Rota | Handler | Acesso |
|
||||||
|--------|-----------------------|---------------------------|--------------------------------|
|
| ------ | ------------------ | ------------------------- | ------------------------------- |
|
||||||
| POST | `/register` | `register_handler` | Público |
|
| POST | `/register` | `register_handler` | Público |
|
||||||
| POST | `/login` | `login_handler` | Público |
|
| POST | `/login` | `login_handler` | Público |
|
||||||
| POST | `/oauth/google` | `google_oauth_handler` | Público |
|
| POST | `/oauth/google` | `google_oauth_handler` | Público |
|
||||||
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
|
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
|
||||||
| POST | `/logout` | `logout_handler` | Autenticado |
|
| POST | `/logout` | `logout_handler` | Autenticado |
|
||||||
| POST | `/forgot-password` | `forgot_password_handler` | Público |
|
| POST | `/forgot-password` | `forgot_password_handler` | Público |
|
||||||
| POST | `/verify-email` | `verify_email_handler` | Público |
|
| POST | `/verify-email` | `verify_email_handler` | Público |
|
||||||
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset)|
|
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
|
||||||
|
|
||||||
### Users — `/api/v1/users`
|
### Users — `/api/v1/users`
|
||||||
|
|
||||||
| Método | Rota | Handler | Acesso |
|
| Método | Rota | Handler | Acesso |
|
||||||
|--------|-------|---------------------|-------------|
|
| ------ | ----- | ------------------- | ----------- |
|
||||||
| GET | `/me` | `get_me_handler` | Autenticado |
|
| GET | `/me` | `get_me_handler` | Autenticado |
|
||||||
| PUT | `/me` | `update_me_handler` | Autenticado |
|
| PUT | `/me` | `update_me_handler` | Autenticado |
|
||||||
|
|
||||||
### Filaments — `/api/v1/filaments`
|
### Filaments — `/api/v1/filaments`
|
||||||
|
|
||||||
| Método | Rota | Handler | Acesso |
|
| Método | Rota | Handler | Acesso |
|
||||||
|--------|-------------------|----------------------------|-------------|
|
| ------ | ---------------- | ------------------------- | ----------- |
|
||||||
| GET | `/` | `list_filaments_handler` | Autenticado |
|
| GET | `/` | `list_filaments_handler` | Autenticado |
|
||||||
| POST | `/` | `create_filament_handler` | Autenticado |
|
| POST | `/` | `create_filament_handler` | Autenticado |
|
||||||
| GET | `/:id` | `get_filament_handler` | Autenticado |
|
| GET | `/:id` | `get_filament_handler` | Autenticado |
|
||||||
| PUT | `/:id` | `update_filament_handler` | Autenticado |
|
| PUT | `/:id` | `update_filament_handler` | Autenticado |
|
||||||
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
|
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
|
||||||
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
|
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
|
||||||
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
|
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
|
||||||
|
|
||||||
**Query params de listagem (`GET /filaments`):**
|
**Query params de listagem (`GET /filaments`):**
|
||||||
|
|
||||||
- `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...)
|
- `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...)
|
||||||
- `brand` — filtra por marca
|
- `brand` — filtra por marca
|
||||||
- `search` — busca em marca, modelo e notas
|
- `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`
|
### Spool Presets — `/api/v1/spool-presets`
|
||||||
|
|
||||||
| Método | Rota | Handler | Acesso |
|
| Método | Rota | Handler | Acesso |
|
||||||
|--------|----------|--------------------------|-------------------------------------|
|
| ------ | ------ | ----------------------- | ------------------------------------ |
|
||||||
| GET | `/` | `list_presets_handler` | Autenticado |
|
| GET | `/` | `list_presets_handler` | Autenticado |
|
||||||
| POST | `/` | `create_preset_handler` | Autenticado |
|
| POST | `/` | `create_preset_handler` | Autenticado |
|
||||||
| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user)|
|
| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user) |
|
||||||
| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user)|
|
| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user) |
|
||||||
|
|
||||||
### Dashboard — `/api/v1/dashboard`
|
### Dashboard — `/api/v1/dashboard`
|
||||||
|
|
||||||
| Método | Rota | Handler | Acesso |
|
| Método | Rota | Handler | Acesso |
|
||||||
|--------|------|----------------------|-------------|
|
| ------ | ---- | ------------------- | ----------- |
|
||||||
| GET | `/` | `dashboard_handler` | Autenticado |
|
| GET | `/` | `dashboard_handler` | Autenticado |
|
||||||
|
|
||||||
**Resposta do dashboard:**
|
**Resposta do dashboard:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"total_stock_kg": 14.2,
|
"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
|
## Geração de QR Code e Etiqueta SVG
|
||||||
|
|
||||||
### QR Code (`GET /filaments/:id/qrcode`)
|
### QR Code (`GET /filaments/:id/qrcode`)
|
||||||
|
|
||||||
- Gera QR Code com deep link: `meowspool://filament/:id` (singular)
|
- Gera QR Code com deep link: `meowspool://filament/:id` (singular)
|
||||||
- Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg`
|
- Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg`
|
||||||
- Biblioteca: crate `qrcode`
|
- Biblioteca: crate `qrcode`
|
||||||
|
|
||||||
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
|
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
|
||||||
|
|
||||||
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30)
|
- 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
|
- Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido
|
||||||
- `Content-Type: image/svg+xml`
|
- `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.
|
- 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.
|
- `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`.
|
- 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).
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ variables:
|
|||||||
- secret: true
|
- secret: true
|
||||||
name: refresh_token
|
name: refresh_token
|
||||||
- name: preset_id
|
- name: preset_id
|
||||||
value: 38e439ba-0289-4bf0-9f4b-f9cb12991167
|
value: 88eab1b9-e979-46db-940f-59cac97df60b
|
||||||
- name: filament_id
|
- name: filament_id
|
||||||
value: 0b371f84-706a-4eaa-bff0-33556fa833b4
|
value: da5e431c-bc2e-4075-aa1c-ce82ee9567b4
|
||||||
|
|||||||
@@ -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)
|
-- Presets built-in do sistema (somente leitura para usuários)
|
||||||
INSERT INTO spool_presets (name, spool_weight_g, is_system) VALUES
|
INSERT INTO spool_presets (name, spool_weight_g, is_system) VALUES
|
||||||
('Bambu Lab (Plástico)', 250, TRUE),
|
('Bambu Lab (Plástico)', 250, TRUE),
|
||||||
('Elegoo (Papelão)', 200, TRUE),
|
('Elegoo (Papelão)', 156, TRUE),
|
||||||
('Creality (Plástico)', 230, TRUE),
|
('Creality (Plástico)', 230, TRUE),
|
||||||
('Prusament (Plástico)', 201, TRUE),
|
('Prusament (Plástico)', 201, TRUE),
|
||||||
('Sunlu (Papelão)', 200, TRUE),
|
('Sunlu (Papelão)', 200, TRUE),
|
||||||
|
|||||||
@@ -69,12 +69,13 @@ impl SpoolPresetRepository for PostgresSpoolPresetRepository {
|
|||||||
let row = sqlx::query_as::<_, SpoolPresetRow>(
|
let row = sqlx::query_as::<_, SpoolPresetRow>(
|
||||||
r#"UPDATE spool_presets
|
r#"UPDATE spool_presets
|
||||||
SET name = $2, spool_weight_g = $3
|
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"#,
|
RETURNING id, name, spool_weight_g, is_system, user_id, created_at"#,
|
||||||
)
|
)
|
||||||
.bind(preset.id)
|
.bind(preset.id)
|
||||||
.bind(&preset.name)
|
.bind(&preset.name)
|
||||||
.bind(preset.spool_weight_g)
|
.bind(preset.spool_weight_g)
|
||||||
|
.bind(preset.user_id)
|
||||||
.fetch_one(self.db.as_ref())
|
.fetch_one(self.db.as_ref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,53 @@
|
|||||||
|
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
# @end expo-cli
|
# @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
|
||||||
@@ -10,24 +10,24 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
|
|||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Componente | Biblioteca | Observação |
|
| Componente | Biblioteca | Observação |
|
||||||
|-----------------------|------------------------------------|-----------------------------------------------|
|
| ----------------- | ----------------------------------- | --------------------------------------------------------- |
|
||||||
| Framework | `expo` ~51 | Managed Workflow |
|
| Framework | `expo` ~51 | Managed Workflow |
|
||||||
| Navigation | `expo-router` ~3 | File-system routing |
|
| Navigation | `expo-router` ~3 | File-system routing |
|
||||||
| Linguagem | TypeScript 5.x | strict mode |
|
| Linguagem | TypeScript 5.x | strict mode |
|
||||||
| Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) |
|
| Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) |
|
||||||
| Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) |
|
| Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) |
|
||||||
| UI | React Native + `@expo/vector-icons`| Ionicons |
|
| UI | React Native + `@expo/vector-icons` | Ionicons |
|
||||||
| Forms | `react-hook-form` + `zod` | Validação em runtime |
|
| Forms | `react-hook-form` + `zod` | Validação em runtime |
|
||||||
| Estado global | `zustand` | Stores em `src/store/` |
|
| Estado global | `zustand` | Stores em `src/store/` |
|
||||||
| HTTP | `axios` | Interceptor Bearer + refresh automático |
|
| HTTP | `axios` | Interceptor Bearer + refresh automático |
|
||||||
| DB local | `expo-sqlite` + SQLCipher | WAL mode, foreign keys |
|
| DB local | `expo-sqlite` + SQLCipher | WAL mode, foreign keys |
|
||||||
| Auth persistência | `expo-secure-store` | JWT cifrado no keychain |
|
| Auth persistência | `expo-secure-store` | JWT cifrado no keychain |
|
||||||
| Safe Area | `react-native-safe-area-context` | |
|
| Safe Area | `react-native-safe-area-context` | |
|
||||||
| Gesture Handler | `react-native-gesture-handler` | |
|
| Gesture Handler | `react-native-gesture-handler` | |
|
||||||
| Animations | `react-native-reanimated` | |
|
| Animations | `react-native-reanimated` | |
|
||||||
| QR Code render | `react-native-qrcode-svg` | Render de QR Code em tela |
|
| 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 |
|
| 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)
|
### Paleta (siamês)
|
||||||
|
|
||||||
| Token | Valor | Uso |
|
| Token | Valor | Uso |
|
||||||
|------------------|-------------|--------------------------------------|
|
| --------------- | ----------- | --------------------------------- |
|
||||||
| `bgBase` | `#1E1B18` | Fundo geral |
|
| `bgBase` | `#1E1B18` | Fundo geral |
|
||||||
| `bgSurface` | `#2A2622` | Cards, containers |
|
| `bgSurface` | `#2A2622` | Cards, containers |
|
||||||
| `bgHover` | `#332F2B` | Hover/pressed em cards |
|
| `bgHover` | `#332F2B` | Hover/pressed em cards |
|
||||||
| `textPrimary` | `#F5EEDC` | Títulos, peso líquido |
|
| `textPrimary` | `#F5EEDC` | Títulos, peso líquido |
|
||||||
| `textSecondary` | `#C9C1B0` | Descrições, labels |
|
| `textSecondary` | `#C9C1B0` | Descrições, labels |
|
||||||
| `accent` | `#38BCC2` | Botões de ação, elementos ativos |
|
| `accent` | `#38BCC2` | Botões de ação, elementos ativos |
|
||||||
| `accentMuted` | `#38BCC226` | Background de badges accent (10%) |
|
| `accentMuted` | `#38BCC226` | Background de badges accent (10%) |
|
||||||
| `stockLow` | `#FF6B6B` | ≤ 15% — vermelho |
|
| `stockLow` | `#FF6B6B` | ≤ 15% — vermelho |
|
||||||
| `stockMedium` | `#FF9F43` | ≤ 35% — laranja |
|
| `stockMedium` | `#FF9F43` | ≤ 35% — laranja |
|
||||||
| `stockOk` | `#38BCC2` | > 35% — accent |
|
| `stockOk` | `#38BCC2` | > 35% — accent |
|
||||||
| `border` | `#3D3830` | Bordas sutis |
|
| `border` | `#3D3830` | Bordas sutis |
|
||||||
| `error` | `#FF6B6B` | Mensagens de erro |
|
| `error` | `#FF6B6B` | Mensagens de erro |
|
||||||
|
|
||||||
### Tipografia
|
### Tipografia
|
||||||
|
|
||||||
@@ -255,10 +255,10 @@ operation TEXT, payload TEXT, created_at TEXT
|
|||||||
Sempre usar aliases em vez de caminhos relativos:
|
Sempre usar aliases em vez de caminhos relativos:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Filament } from '@domain/Filament';
|
import { Filament } from "@domain/Filament";
|
||||||
import { useFilamentStore } from '@store/filamentStore';
|
import { useFilamentStore } from "@store/filamentStore";
|
||||||
import { colors } from '@shared/theme';
|
import { colors } from "@shared/theme";
|
||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from "@presentation/components/ui/Button";
|
||||||
```
|
```
|
||||||
|
|
||||||
### Componentes de tela
|
### Componentes de tela
|
||||||
@@ -283,43 +283,43 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
| Método | Rota | Descrição |
|
| Método | Rota | Descrição |
|
||||||
|--------|-------------------------------|------------------------------|
|
| ------ | ----------------------- | --------------------------- |
|
||||||
| POST | `/auth/login` | Login email/senha |
|
| POST | `/auth/login` | Login email/senha |
|
||||||
| POST | `/auth/register` | Cadastro |
|
| POST | `/auth/register` | Cadastro |
|
||||||
| POST | `/auth/google` | OAuth Google |
|
| POST | `/auth/google` | OAuth Google |
|
||||||
| POST | `/auth/refresh` | Refresh token |
|
| POST | `/auth/refresh` | Refresh token |
|
||||||
| POST | `/auth/logout` | Logout |
|
| POST | `/auth/logout` | Logout |
|
||||||
| POST | `/auth/forgot-password` | Solicitar reset de senha |
|
| POST | `/auth/forgot-password` | Solicitar reset de senha |
|
||||||
| POST | `/auth/reset-password` | Confirmar reset com token |
|
| POST | `/auth/reset-password` | Confirmar reset com token |
|
||||||
| POST | `/auth/verify-email` | Verificar e-mail com código |
|
| POST | `/auth/verify-email` | Verificar e-mail com código |
|
||||||
|
|
||||||
### Filaments
|
### Filaments
|
||||||
|
|
||||||
| Método | Rota | Descrição |
|
| Método | Rota | Descrição |
|
||||||
|--------|-------------------------------|------------------------------|
|
| ------ | ----------------------- | -------------------- |
|
||||||
| GET | `/filaments` | Listar (com filtros) |
|
| GET | `/filaments` | Listar (com filtros) |
|
||||||
| POST | `/filaments` | Criar |
|
| POST | `/filaments` | Criar |
|
||||||
| GET | `/filaments/:id` | Detalhe |
|
| GET | `/filaments/:id` | Detalhe |
|
||||||
| PATCH | `/filaments/:id` | Atualizar |
|
| PATCH | `/filaments/:id` | Atualizar |
|
||||||
| DELETE | `/filaments/:id` | Excluir |
|
| DELETE | `/filaments/:id` | Excluir |
|
||||||
| GET | `/filaments/:id/qrcode` | QR Code SVG |
|
| GET | `/filaments/:id/qrcode` | QR Code SVG |
|
||||||
| GET | `/filaments/:id/label` | Etiqueta SVG |
|
| GET | `/filaments/:id/label` | Etiqueta SVG |
|
||||||
|
|
||||||
### Spool Presets
|
### Spool Presets
|
||||||
|
|
||||||
| Método | Rota | Descrição |
|
| Método | Rota | Descrição |
|
||||||
|--------|-------------------------------|------------------------------|
|
| ------ | -------------------- | --------------------------- |
|
||||||
| GET | `/spool-presets` | Listar (sistema + usuário) |
|
| GET | `/spool-presets` | Listar (sistema + usuário) |
|
||||||
| POST | `/spool-presets` | Criar preset do usuário |
|
| POST | `/spool-presets` | Criar preset do usuário |
|
||||||
| PATCH | `/spool-presets/:id` | Atualizar preset do usuário |
|
| PATCH | `/spool-presets/:id` | Atualizar preset do usuário |
|
||||||
| DELETE | `/spool-presets/:id` | Excluir preset do usuário |
|
| DELETE | `/spool-presets/:id` | Excluir preset do usuário |
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||
| Método | Rota | Descrição |
|
| Método | Rota | Descrição |
|
||||||
|--------|-------------------------------|------------------------------|
|
| ------ | ------------ | ---------------------------- |
|
||||||
| GET | `/dashboard` | Dados agregados do dashboard |
|
| 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||||
|
// ... 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
|
## Executando o Projeto
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -354,6 +500,7 @@ mise exec -- npx expo run:ios
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Variável de ambiente**: crie `mobile/.env` com:
|
> **Variável de ambiente**: crie `mobile/.env` com:
|
||||||
|
>
|
||||||
> ```
|
> ```
|
||||||
> EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1
|
> EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1
|
||||||
> ```
|
> ```
|
||||||
|
|||||||
@@ -87,13 +87,13 @@ android {
|
|||||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
namespace "com.meowspool"
|
namespace 'com.meowspool.app'
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.meowspool"
|
applicationId 'com.meowspool.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0.0"
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<!-- OPTIONAL PERMISSIONS, REMOVE WHATEVER YOU DO NOT NEED -->
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<!-- These require runtime permissions on M -->
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
<!-- END OPTIONAL PERMISSIONS -->
|
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<!-- Support checking for http(s) links via the Linking API -->
|
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||||
<application android:name="com.meowspool.app.MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true">
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
<activity android:name="com.meowspool.app.MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="meowspool"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package com.meowspool.app
|
package com.meowspool.app
|
||||||
|
import expo.modules.splashscreen.SplashScreenManager
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -10,15 +11,15 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
|
|||||||
|
|
||||||
import expo.modules.ReactActivityDelegateWrapper
|
import expo.modules.ReactActivityDelegateWrapper
|
||||||
|
|
||||||
import com.meowspool.R
|
|
||||||
import com.meowspool.BuildConfig
|
|
||||||
|
|
||||||
class MainActivity : ReactActivity() {
|
class MainActivity : ReactActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// Set the theme to AppTheme BEFORE onCreate to support
|
// Set the theme to AppTheme BEFORE onCreate to support
|
||||||
// coloring the background, status bar, and navigation bar.
|
// coloring the background, status bar, and navigation bar.
|
||||||
// This is required for expo-splash-screen.
|
// 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)
|
super.onCreate(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import com.facebook.react.defaults.DefaultReactNativeHost
|
|||||||
import expo.modules.ApplicationLifecycleDispatcher
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
import expo.modules.ReactNativeHostWrapper
|
import expo.modules.ReactNativeHostWrapper
|
||||||
|
|
||||||
import com.meowspool.BuildConfig
|
|
||||||
|
|
||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 205 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 876 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 590 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 0 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 393 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 542 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<resources>
|
<resources>
|
||||||
<color name="splashscreen_background">#FFFFFF</color>
|
<color name="splashscreen_background">#1E1B18</color>
|
||||||
|
<color name="iconBackground">#1E1B18</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
<color name="colorPrimaryDark">#1E1B18</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">MeowSpool</string>
|
<string name="app_name">MeowSpool</string>
|
||||||
|
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||||
|
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||||
|
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
<resources>
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="android:statusBarColor">#1E1B18</item>
|
||||||
</style>
|
</style>
|
||||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||||
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
|
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||||
|
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -10,13 +10,12 @@
|
|||||||
# Specifies the JVM arguments used for the daemon process.
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
# 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.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
# 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
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
org.gradle.caching=false
|
|
||||||
|
|
||||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
# 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
|
# 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.
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
expo.useLegacyPackaging=false
|
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
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput,
|
View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, RefreshControl,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useFilamentStore } from '@store/filamentStore';
|
import { useFilamentStore } from '@store/filamentStore';
|
||||||
|
import { listFilamentsUseCase } from '@infrastructure/container';
|
||||||
|
import { useAuthStore } from '@store/authStore';
|
||||||
import { FilamentCard } from '@presentation/components/filament/FilamentCard';
|
import { FilamentCard } from '@presentation/components/filament/FilamentCard';
|
||||||
import type { Filament } from '@domain/Filament';
|
import type { Filament } from '@domain/Filament';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
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 {
|
export default function InventoryScreen(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { filaments } = useFilamentStore();
|
const { filaments, setFilaments } = useFilamentStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<string>('Todos');
|
const [activeTab, setActiveTab] = useState<string>('Todos');
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const filtered = filaments.filter((f) => {
|
const filtered = filaments.filter((f) => {
|
||||||
const matchMaterial = activeTab === 'Todos' || f.material === activeTab;
|
const matchMaterial = activeTab === 'Todos' || f.material === activeTab;
|
||||||
@@ -33,6 +37,18 @@ export default function InventoryScreen(): React.ReactElement {
|
|||||||
return matchMaterial && matchSearch;
|
return matchMaterial && matchSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function onRefresh(): Promise<void> {
|
||||||
|
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 {
|
function renderItem({ item }: { item: Filament }): React.ReactElement {
|
||||||
return <FilamentCard filament={item} />;
|
return <FilamentCard filament={item} />;
|
||||||
}
|
}
|
||||||
@@ -96,6 +112,9 @@ export default function InventoryScreen(): React.ReactElement {
|
|||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} tintColor={colors.accent} />
|
||||||
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Ionicons name="layers-outline" size={40} color={colors.textSecondary} />
|
<Ionicons name="layers-outline" size={40} color={colors.textSecondary} />
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useFilamentStore } from '@store/filamentStore';
|
import { useFilamentStore } from '@store/filamentStore';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
|
import { useAuthStore } from '@store/authStore';
|
||||||
|
import { deleteFilamentUseCase } from '@infrastructure/container';
|
||||||
import { calcFilamentPercentage } from '@domain/Filament';
|
import { calcFilamentPercentage } from '@domain/Filament';
|
||||||
import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme';
|
import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme';
|
||||||
import { formatWeight } from '@shared/utils/filament';
|
import { formatWeight } from '@shared/utils/filament';
|
||||||
@@ -52,9 +54,16 @@ export default function FilamentDetailScreen(): React.ReactElement {
|
|||||||
{
|
{
|
||||||
text: 'Excluir',
|
text: 'Excluir',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: async () => {
|
||||||
removeFilament(filament!.id);
|
try {
|
||||||
router.back();
|
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.');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
import { useFilamentStore } from '@store/filamentStore';
|
import { useFilamentStore } from '@store/filamentStore';
|
||||||
|
import { useAuthStore } from '@store/authStore';
|
||||||
|
import { deleteFilamentUseCase, updateFilamentUseCase } from '@infrastructure/container';
|
||||||
import { Input } from '@presentation/components/ui/Input';
|
import { Input } from '@presentation/components/ui/Input';
|
||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
import { Card } from '@presentation/components/ui/Card';
|
import { Card } from '@presentation/components/ui/Card';
|
||||||
import { calcNetWeight } from '@domain/Filament';
|
import { calcNetWeight } from '@domain/Filament';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||||
import { MATERIALS, type Material } from '@shared/constants';
|
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({
|
const schema = z.object({
|
||||||
brand: z.string().min(1, 'Marca obrigatória'),
|
brand: z.string().min(1, 'Marca obrigatória'),
|
||||||
@@ -48,6 +50,7 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
|
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
|
||||||
|
|
||||||
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
|
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
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<void> => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Excluir filamento',
|
'Confirmar Deleção',
|
||||||
`Deseja excluir "${filament!.brand}${filament!.model ? ` ${filament!.model}` : ''}"? Esta ação não pode ser desfeita.`,
|
'Tem certeza que deseja deletar este filamento?',
|
||||||
[
|
[
|
||||||
{ text: 'Cancelar', style: 'cancel' },
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Excluir',
|
text: 'Deletar',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: async () => {
|
||||||
removeFilament(filament!.id);
|
const user = useAuthStore.getState().user;
|
||||||
router.replace('/(app)/(tabs)/inventory');
|
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<void> {
|
async function onSubmit(data: FormData): Promise<void> {
|
||||||
if (!selectedPresetId) {
|
if (!selectedPresetId) {
|
||||||
@@ -163,7 +209,7 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
|
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<TouchableOpacity style={styles.colorAddBtn}>
|
<TouchableOpacity style={styles.colorAddBtn} onPress={handleOpenColorPicker}>
|
||||||
<Ionicons name="add" size={18} color={colors.textSecondary} />
|
<Ionicons name="add" size={18} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { colors, typography, spacing, radius } from '@shared/theme';
|
|||||||
import { MATERIALS, type Material } from '@shared/constants';
|
import { MATERIALS, type Material } from '@shared/constants';
|
||||||
import { formatWeight } from '@shared/utils/filament';
|
import { formatWeight } from '@shared/utils/filament';
|
||||||
import { createFilamentUseCase } from '@infrastructure/container';
|
import { createFilamentUseCase } from '@infrastructure/container';
|
||||||
|
import { isValidHex, normalizeHex } from '@shared/utils/filament';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
brand: z.string().min(1, 'Marca obrigatória'),
|
brand: z.string().min(1, 'Marca obrigatória'),
|
||||||
@@ -48,6 +49,7 @@ export default function NewFilamentScreen(): React.ReactElement {
|
|||||||
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
|
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
|
||||||
|
|
||||||
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
|
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,6 +69,34 @@ export default function NewFilamentScreen(): React.ReactElement {
|
|||||||
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
|
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
|
||||||
: 0;
|
: 0;
|
||||||
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
|
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<void> {
|
async function onSubmit(data: FormData): Promise<void> {
|
||||||
if (!selectedPresetId) {
|
if (!selectedPresetId) {
|
||||||
@@ -102,7 +132,7 @@ export default function NewFilamentScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={styles.safe}> onPress={handleOpenColorPicker}
|
||||||
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -130,7 +160,7 @@ export default function NewFilamentScreen(): React.ReactElement {
|
|||||||
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
|
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<TouchableOpacity style={styles.colorAddBtn}>
|
<TouchableOpacity style={styles.colorAddBtn} onPress={handleOpenColorPicker}>
|
||||||
<Ionicons name="add" size={18} color={colors.textSecondary} />
|
<Ionicons name="add" size={18} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||