feat: add domain models and repositories for user, spool presets, and filaments

- Introduced SpoolPreset and User domain models with necessary DTOs and utility functions.
- Created AuthRepository, FilamentRepository, and SpoolPresetRepository interfaces for authentication and data management.
- Implemented UI components for filament display, including ColorSwatch, FilamentCard, and StockBar.
- Developed layout components such as Header and Screen for consistent app structure.
- Added reusable UI components like Badge, Button, Card, and Input for better user interaction.
- Established global constants and theme settings for consistent styling across the application.
- Implemented utility functions for filament calculations and formatting.
- Created Zustand stores for managing authentication, filament, and preset states.
- Configured TypeScript settings for improved development experience.
This commit is contained in:
2026-03-14 10:36:13 -03:00
parent abdc2fe8ce
commit d7fb768d3b
76 changed files with 19681 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# URL base da API do backend Rust
# Em desenvolvimento, aponta para o localhost na porta do backend (8080)
# No Android Emulator, use http://10.0.2.2:8080/api/v1
# No dispositivo físico, use o IP da sua máquina: http://192.168.x.x:8080/api/v1
EXPO_PUBLIC_API_URL=http://localhost:8080/api/v1
# OAuth Google (mesmo Client ID usado no backend)
EXPO_PUBLIC_GOOGLE_CLIENT_ID=seu_google_client_id.apps.googleusercontent.com
# Deep link scheme (deve bater com app.json → scheme)
EXPO_PUBLIC_APP_SCHEME=meowspool
# Ambiente
EXPO_PUBLIC_APP_ENV=development
+8
View File
@@ -0,0 +1,8 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
+3
View File
@@ -0,0 +1,3 @@
{
"devices": []
}
+14
View File
@@ -0,0 +1,14 @@
/* eslint-disable */
import * as Router from 'expo-router';
export * from 'expo-router';
declare module 'expo-router' {
export namespace ExpoRouter {
export interface __routes<T extends string | object = string> {
hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/add` | `/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/config` | `/config`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/inventory` | `/inventory`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/config/presets/new` | `/config/presets/new`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/inventory/filters` | `/inventory/filters`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/inventory/new` | `/inventory/new`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/forgot-password` | `/forgot-password`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/password-reset-done` | `/password-reset-done`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/register` | `/register`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/reset-password` | `/reset-password`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/verify-email` | `/verify-email`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/config/presets/[id]/edit` | `/config/presets/[id]/edit`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/filaments/[id]/label` | `/filaments/[id]/label`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/filaments/[id]/qrcode` | `/filaments/[id]/qrcode`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/inventory/[id]` | `/inventory/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/inventory/[id]/edit` | `/inventory/[id]/edit`, params: Router.UnknownInputParams & { id: string | number; } };
hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/add` | `/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/config` | `/config`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/home` | `/home`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/inventory` | `/inventory`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}/config/presets/new` | `/config/presets/new`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}/inventory/filters` | `/inventory/filters`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}/inventory/new` | `/inventory/new`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/forgot-password` | `/forgot-password`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/password-reset-done` | `/password-reset-done`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/register` | `/register`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/reset-password` | `/reset-password`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/verify-email` | `/verify-email`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(app)'}/config/presets/[id]/edit` | `/config/presets/[id]/edit`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(app)'}/filaments/[id]/label` | `/filaments/[id]/label`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(app)'}/filaments/[id]/qrcode` | `/filaments/[id]/qrcode`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(app)'}/inventory/[id]` | `/inventory/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(app)'}/inventory/[id]/edit` | `/inventory/[id]/edit`, params: Router.UnknownOutputParams & { id: string; } };
href: Router.RelativePathString | Router.ExternalPathString | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(app)'}${'/(tabs)'}/add${`?${string}` | `#${string}` | ''}` | `/add${`?${string}` | `#${string}` | ''}` | `${'/(app)'}${'/(tabs)'}/config${`?${string}` | `#${string}` | ''}` | `/config${`?${string}` | `#${string}` | ''}` | `${'/(app)'}${'/(tabs)'}/home${`?${string}` | `#${string}` | ''}` | `/home${`?${string}` | `#${string}` | ''}` | `${'/(app)'}${'/(tabs)'}/inventory${`?${string}` | `#${string}` | ''}` | `/inventory${`?${string}` | `#${string}` | ''}` | `${'/(app)'}${'/(tabs)'}/profile${`?${string}` | `#${string}` | ''}` | `/profile${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/config/presets/new${`?${string}` | `#${string}` | ''}` | `/config/presets/new${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/inventory/filters${`?${string}` | `#${string}` | ''}` | `/inventory/filters${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/inventory/new${`?${string}` | `#${string}` | ''}` | `/inventory/new${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/forgot-password${`?${string}` | `#${string}` | ''}` | `/forgot-password${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/login${`?${string}` | `#${string}` | ''}` | `/login${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/password-reset-done${`?${string}` | `#${string}` | ''}` | `/password-reset-done${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/register${`?${string}` | `#${string}` | ''}` | `/register${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/reset-password${`?${string}` | `#${string}` | ''}` | `/reset-password${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/verify-email${`?${string}` | `#${string}` | ''}` | `/verify-email${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/add` | `/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/config` | `/config`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/inventory` | `/inventory`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/config/presets/new` | `/config/presets/new`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/inventory/filters` | `/inventory/filters`; params?: Router.UnknownInputParams; } | { pathname: `${'/(app)'}/inventory/new` | `/inventory/new`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/forgot-password` | `/forgot-password`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/password-reset-done` | `/password-reset-done`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/register` | `/register`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/reset-password` | `/reset-password`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/verify-email` | `/verify-email`; params?: Router.UnknownInputParams; } | `${'/(app)'}/config/presets/${Router.SingleRoutePart<T>}/edit${`?${string}` | `#${string}` | ''}` | `/config/presets/${Router.SingleRoutePart<T>}/edit${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/filaments/${Router.SingleRoutePart<T>}/label${`?${string}` | `#${string}` | ''}` | `/filaments/${Router.SingleRoutePart<T>}/label${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/filaments/${Router.SingleRoutePart<T>}/qrcode${`?${string}` | `#${string}` | ''}` | `/filaments/${Router.SingleRoutePart<T>}/qrcode${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/inventory/${Router.SingleRoutePart<T>}${`?${string}` | `#${string}` | ''}` | `/inventory/${Router.SingleRoutePart<T>}${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/inventory/${Router.SingleRoutePart<T>}/edit${`?${string}` | `#${string}` | ''}` | `/inventory/${Router.SingleRoutePart<T>}/edit${`?${string}` | `#${string}` | ''}` | { pathname: `${'/(app)'}/config/presets/[id]/edit` | `/config/presets/[id]/edit`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/filaments/[id]/label` | `/filaments/[id]/label`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/filaments/[id]/qrcode` | `/filaments/[id]/qrcode`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/inventory/[id]` | `/inventory/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(app)'}/inventory/[id]/edit` | `/inventory/[id]/edit`, params: Router.UnknownInputParams & { id: string | number; } };
}
}
}
+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "22"
+353
View File
@@ -0,0 +1,353 @@
# MeowSpool Mobile — Agent Guide
## Visão Geral
Aplicação mobile do **MeowSpool** construída com **React Native** + **Expo** (SDK ~51), usando **expo-router** para navegação baseada em sistema de arquivos. A arquitetura espelha o backend Rust: **Clean Code / Hexagonal**, com camadas bem delimitadas do domínio até a apresentação.
O app é **offline-first**: todos os dados são persistidos localmente em **SQLite** (expo-sqlite + SQLCipher) e sincronizados com o backend Rust em background usando estratégia **last-write-wins** via campo `updatedAt`.
---
## 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`) |
| 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` | |
---
## Arquitetura de Camadas
```
src/
├── domain/ ← Entidades puras (sem dependências externas)
├── ports/ ← Interfaces/contratos (traits equivalentes ao Rust)
├── application/ ← Use Cases (orquestram domínio + ports)
├── adapters/
│ ├── remote/ ← Implementações HTTP (Axios → API Rust)
│ └── local/ ← Implementações SQLite (expo-sqlite)
├── store/ ← Estado em memória (Zustand) — cache das queries
├── shared/ ← Design tokens, constantes, utilitários
└── presentation/
└── components/ ← Componentes UI reutilizáveis
```
### Regra de dependência
```
presentation → store → application → ports ← adapters
domain
```
Nenhuma camada interna importa de camadas externas. Os `adapters` implementam os `ports`.
---
## Estrutura de Arquivos
```
mobile/
├── .mise.toml ← node 22 LTS
├── app.json ← Expo config, scheme "meowspool"
├── babel.config.js ← module-resolver + reanimated
├── tsconfig.json ← aliases @domain, @ports, @application,
│ @adapters, @presentation, @store, @shared
├── index.js ← expo-router entry point
├── package.json
├── app/ ← expo-router file-system routes
│ ├── _layout.tsx ← Root layout (carrega sessão)
│ ├── (auth)/
│ │ ├── _layout.tsx ← Redireciona se já autenticado
│ │ ├── login.tsx ← G3-0
│ │ ├── register.tsx ← IX-0
│ │ ├── forgot-password.tsx ← L2-0
│ │ ├── verify-email.tsx ← RU-0
│ │ ├── reset-password.tsx ← 117-0
│ │ └── password-reset-done.tsx ← 135-0
│ └── (app)/
│ ├── _layout.tsx ← Stack autenticado
│ ├── (tabs)/
│ │ ├── _layout.tsx ← Bottom tabs: Início|Estoque|[+]|Config|Perfil
│ │ ├── home.tsx ← 1-0 Dashboard
│ │ ├── inventory.tsx ← CW-0 Inventário
│ │ ├── add.tsx ← FAB → redireciona para /inventory/new
│ │ ├── config.tsx ← M4-0 Presets de Carretéis
│ │ └── profile.tsx ← 1LQ-0 Perfil
│ ├── inventory/
│ │ ├── new.tsx ← 2X-0 Cadastro de Filamento
│ │ ├── filters.tsx ← 17H-0 Filtros (bottom sheet)
│ │ ├── [id].tsx ← 6L-0 Detalhe do Filamento
│ │ └── [id]/
│ │ └── edit.tsx ← 13O-0 Editar Filamento
│ ├── config/
│ │ └── presets/
│ │ ├── new.tsx ← QM-0 Novo Preset
│ │ └── [id]/
│ │ └── edit.tsx ← 1KD-0 Editar Preset
│ └── filaments/
│ └── [id]/
│ ├── qrcode.tsx ← 1CF-0 Ver QR Code
│ └── label.tsx ← 1FZ-0 Exportar Etiqueta SVG
└── src/
├── domain/
│ ├── Filament.ts ← interface Filament, calcNetWeight
│ ├── SpoolPreset.ts ← interface SpoolPreset, isUserOwnedPreset
│ ├── User.ts ← interface User, AuthSession, DTOs
│ └── Dashboard.ts ← DashboardData, MaterialSummary, etc.
├── ports/
│ ├── FilamentRepository.ts ← interface IFilamentRepository
│ ├── SpoolPresetRepository.ts ← interface ISpoolPresetRepository
│ └── AuthRepository.ts ← interface IAuthRepository
├── application/
│ ├── filament/
│ │ ├── CreateFilamentUseCase.ts
│ │ ├── UpdateFilamentUseCase.ts
│ │ ├── ListFilamentsUseCase.ts
│ │ └── DeleteFilamentUseCase.ts
│ ├── preset/
│ │ └── PresetUseCases.ts ← List, Create, Update, Delete
│ └── auth/
│ └── AuthUseCases.ts ← Login, Register, Google, Logout, etc.
├── adapters/
│ ├── remote/
│ │ ├── httpClient.ts ← Axios + interceptors JWT + refresh
│ │ ├── ApiAuthRepository.ts ← /api/v1/auth/*
│ │ ├── ApiFilamentRepository.ts ← /api/v1/filaments/*
│ │ └── ApiSpoolPresetRepository.ts ← /api/v1/spool-presets/*
│ └── local/
│ ├── database.ts ← init SQLite, WAL, foreign keys, tabelas
│ ├── LocalFilamentRepository.ts
│ └── LocalSpoolPresetRepository.ts
├── store/
│ ├── authStore.ts ← Zustand: sessão, SecureStore
│ ├── filamentStore.ts ← Zustand: lista + filtros em memória
│ └── presetStore.ts ← Zustand: systemPresets + userPresets
├── shared/
│ ├── theme.ts ← Design tokens (cores, tipografia, espaçamento)
│ ├── constants.ts ← API_BASE_URL, SecureStore keys, MATERIALS
│ └── utils/
│ └── filament.ts ← calcFilamentPercentage, formatWeight, etc.
└── presentation/
└── components/
├── ui/
│ ├── Button.tsx ← primary, secondary, ghost, danger
│ ├── Input.tsx ← label, leftIcon, rightLabel, isPassword, error
│ ├── Card.tsx ← container surface com borda
│ └── Badge.tsx ← badge dinâmico de estoque
├── layout/
│ ├── Screen.tsx ← SafeArea + scroll + keyboardAvoiding
│ └── Header.tsx ← título centralizado, back, slot direito
└── filament/
├── StockBar.tsx ← barra de progresso + badge numérico
├── ColorSwatch.tsx ← quadrado/círculo com cor hex
└── FilamentCard.tsx ← card de lista
```
---
## Design Tokens
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 |
### Tipografia
- UI: **Inter**
- Mono (slugs, hex, URLs): **JetBrains Mono**
- App é **dark-mode exclusivo** — nenhum suporte a light mode
---
## Navegação (expo-router)
### Estrutura de grupos
```
(auth) ← sem autenticação; redireciona para (app) se sessão válida
(app) ← com autenticação; redireciona para (auth) se sem sessão
(tabs) ← bottom tabs fixos
```
### Deep links
- Scheme: `meowspool://`
- Filamento: `meowspool://filament/<id>``/(app)/inventory/<id>`
- QR público: `meowspool.app/f/<slug>` (web)
---
## Estratégia Offline-First
1. **Escrita**: toda mutação persiste primeiro no SQLite (`synced = 0`) e enfileira em `sync_queue`.
2. **Leitura**: lê sempre do SQLite; a API é usada apenas para sync.
3. **Sync**: background job consome `sync_queue` e envia para o backend Rust.
4. **Conflito**: last-write-wins via `updatedAt` (ISO 8601). O backend é a fonte de verdade em conflitos.
5. **Auth offline**: token armazenado no `expo-secure-store`; refresh automático via interceptor Axios.
### Schema SQLite
```sql
-- filaments
id TEXT PRIMARY KEY, user_id TEXT, material TEXT, brand TEXT, model TEXT,
color_hex TEXT, spool_preset_id TEXT, total_weight_g REAL, net_weight_g REAL,
temp_hotend_c REAL, temp_bed_c REAL, flow_factor_pct REAL, notes TEXT,
synced INTEGER DEFAULT 0, updated_at TEXT, created_at TEXT
-- spool_presets
id TEXT PRIMARY KEY, user_id TEXT, name TEXT, spool_weight_g REAL,
is_system INTEGER DEFAULT 0, synced INTEGER DEFAULT 0, created_at TEXT
-- sync_queue
id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT, entity_id TEXT,
operation TEXT, payload TEXT, created_at TEXT
```
---
## Convenções de Código
### Nomenclatura
- **Componentes React**: PascalCase (`FilamentCard.tsx`)
- **Hooks**: camelCase com prefixo `use` (`useFilamentStore`)
- **Use Cases**: PascalCase com sufixo `UseCase` (`CreateFilamentUseCase`)
- **Repositórios**: PascalCase com prefixo de implementação (`ApiFilamentRepository`, `LocalFilamentRepository`)
- **Stores**: camelCase (`filamentStore.ts`), exportados como `useXxxStore`
### Imports
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';
```
### Componentes de tela
- Toda tela usa `SafeAreaView` com `backgroundColor: colors.bgBase`
- Header manual (não usa o header do expo-router): `headerShown: false`
- CTA fixo: `View` com `padding: spacing[5]` + `borderTopWidth: 1` + `borderTopColor: colors.border`
- ScrollViews com `showsVerticalScrollIndicator={false}` e `keyboardShouldPersistTaps="handled"`
### Formulários
- `react-hook-form` + `zodResolver` para todos os formulários
- Validações no schema Zod (nunca inline)
- `isLoading` local no componente durante submit
- Erros de API exibidos via `Alert.alert`
---
## API do Backend (rotas relevantes)
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 |
### 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 |
### 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 |
### Dashboard
| Método | Rota | Descrição |
|--------|-------------------------------|------------------------------|
| GET | `/dashboard` | Dados agregados do dashboard |
---
## Próximos Passos (integrações pendentes)
- [ ] Instanciar e injetar repositórios concretos nos Use Cases (DI container simples ou Context)
- [ ] Implementar sync background com `sync_queue` SQLite → API
- [ ] Integrar `react-native-qrcode-svg` para render real do QR Code
- [ ] Gerar SVG de etiqueta (integração com `/filaments/:id/label` do backend)
- [ ] Expo Notifications para alertas de estoque baixo
- [ ] Google OAuth com `expo-auth-session`
- [ ] Testes de integração com Jest + Testing Library
---
## Executando o Projeto
```bash
# Instalar dependências (na pasta mobile/)
mise install # garante Node 22
npm install
# Iniciar servidor de desenvolvimento
npx expo start
# iOS
npx expo run:ios
# Android
npx expo run:android
```
> **Variável de ambiente**: crie `mobile/.env` com:
> ```
> EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1
> ```
+38
View File
@@ -0,0 +1,38 @@
{
"expo": {
"name": "MeowSpool",
"slug": "meowspool",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#1E1B18"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.meowspool.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1E1B18"
},
"package": "com.meowspool.app"
},
"web": {
"favicon": "./assets/favicon.png"
},
"scheme": "meowspool",
"plugins": [
"expo-router",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import { Tabs, Redirect } from 'expo-router';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@store/authStore';
import { colors, radius, spacing } from '@shared/theme';
/**
* Layout do app autenticado com Bottom Tab Navigator.
* 4 abas + FAB central: Início | Estoque | [+] | Config | Perfil
*/
export default function AppLayout(): React.ReactElement {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: styles.tabBar,
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: styles.tabLabel,
}}
>
<Tabs.Screen
name="home"
options={{
title: 'Início',
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="inventory"
options={{
title: 'Estoque',
tabBarIcon: ({ color, size }) => (
<Ionicons name="layers-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="add"
options={{
title: '',
tabBarIcon: () => (
<View style={styles.fab}>
<Ionicons name="add" size={28} color={colors.bgBase} />
</View>
),
tabBarButton: (props) => (
<TouchableOpacity {...props} style={styles.fabWrapper} activeOpacity={0.8} />
),
}}
/>
<Tabs.Screen
name="config"
options={{
title: 'Config',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Perfil',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
tabBar: {
backgroundColor: colors.bgSurface,
borderTopColor: colors.border,
borderTopWidth: 1,
height: 72,
paddingBottom: spacing[2],
},
tabLabel: {
fontSize: 11,
fontFamily: 'Inter',
},
fabWrapper: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
top: -8,
},
fab: {
width: 56,
height: 56,
borderRadius: radius.full,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
});
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { useRouter } from 'expo-router';
import { Screen } from '@presentation/components/layout/Screen';
/**
* Tab "add" — apenas redireciona para o formulário de novo filamento.
* O FAB central da tab bar chama esta rota.
*/
export default function AddTab(): React.ReactElement {
const router = useRouter();
React.useEffect(() => {
router.replace('/(app)/inventory/new');
}, [router]);
return <Screen />;
}
+238
View File
@@ -0,0 +1,238 @@
import React from 'react';
import {
View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { isUserOwnedPreset } from '@domain/SpoolPreset';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela de Presets de Carretéis — M4-0
*/
export default function ConfigScreen(): React.ReactElement {
const router = useRouter();
const { systemPresets, userPresets, removePreset } = usePresetStore();
function handleDelete(id: string, name: string): void {
Alert.alert(
'Excluir preset',
`Deseja excluir o preset "${name}"?`,
[
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Excluir', style: 'destructive', onPress: () => removePreset(id) },
],
);
}
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.headerSub}>Configurações</Text>
<Text style={styles.headerTitle}>Presets de Carretéis</Text>
</View>
<TouchableOpacity
style={styles.addBtn}
onPress={() => router.push('/(app)/config/presets/new' as never)}
>
<Ionicons name="add" size={24} color={colors.bgBase} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false} style={styles.scroll}>
{/* Presets do Sistema */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionLabel}>PRESETS DO SISTEMA</Text>
<View style={styles.readonlyBadge}>
<Text style={styles.readonlyText}>Somente leitura</Text>
</View>
</View>
{systemPresets.map((preset) => (
<View key={preset.id} style={styles.presetRow}>
<View style={styles.presetIcon}>
<Ionicons name="disc-outline" size={20} color={colors.textSecondary} />
</View>
<View style={styles.presetInfo}>
<Text style={styles.presetName}>{preset.name}</Text>
<Text style={styles.presetSub}>
{/* type field not in SpoolPreset domain yet — show dash */}
Carretel · {preset.spoolWeightG}g
</Text>
</View>
<View style={styles.weightBadge}>
<Text style={styles.weightBadgeText}>{preset.spoolWeightG}g</Text>
</View>
</View>
))}
</View>
{/* Meus Presets */}
{userPresets.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionLabel}>MEUS PRESETS</Text>
{userPresets.map((preset) => (
<View key={preset.id} style={styles.presetRow}>
<View style={styles.presetIcon}>
<Ionicons name="disc-outline" size={20} color={colors.textSecondary} />
</View>
<View style={styles.presetInfo}>
<Text style={styles.presetName}>{preset.name}</Text>
<Text style={styles.presetSub}>Customizado · {preset.spoolWeightG}g</Text>
</View>
<View style={styles.userPresetActions}>
<View style={styles.weightBadge}>
<Text style={styles.weightBadgeText}>{preset.spoolWeightG}g</Text>
</View>
{isUserOwnedPreset(preset) && (
<>
<TouchableOpacity
onPress={() => router.push(`/(app)/config/presets/${preset.id}/edit` as never)}
>
<Ionicons name="create-outline" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(preset.id, preset.name)}>
<Ionicons name="trash-outline" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</>
)}
</View>
</View>
))}
</View>
)}
{userPresets.length === 0 && (
<View style={styles.emptyUser}>
<Text style={styles.emptyText}>Nenhum preset personalizado ainda.</Text>
<TouchableOpacity onPress={() => router.push('/(app)/config/presets/new' as never)}>
<Text style={styles.emptyLink}>+ Criar preset</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing[5],
paddingTop: spacing[5],
paddingBottom: spacing[4],
},
headerSub: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
addBtn: {
width: 44,
height: 44,
borderRadius: radius.lg,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
scroll: { flex: 1, paddingHorizontal: spacing[5] },
section: { marginBottom: spacing[6] },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
marginBottom: spacing[3],
},
sectionLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
readonlyBadge: {
backgroundColor: colors.accentMuted,
paddingHorizontal: spacing[3],
paddingVertical: 3,
borderRadius: radius.full,
},
readonlyText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.accent,
},
presetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
backgroundColor: colors.bgSurface,
borderRadius: radius.lg,
padding: spacing[4],
marginBottom: spacing[2],
borderWidth: 1,
borderColor: colors.border,
},
presetIcon: {
width: 40,
height: 40,
borderRadius: radius.md,
backgroundColor: colors.bgHover,
alignItems: 'center',
justifyContent: 'center',
},
presetInfo: { flex: 1 },
presetName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.textPrimary,
},
presetSub: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
marginTop: 2,
},
weightBadge: {
backgroundColor: colors.bgHover,
paddingHorizontal: spacing[3],
paddingVertical: 5,
borderRadius: radius.sm,
},
weightBadgeText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.textPrimary,
},
userPresetActions: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
emptyUser: {
alignItems: 'center',
paddingVertical: spacing[8],
gap: spacing[3],
},
emptyText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
emptyLink: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
},
});
+244
View File
@@ -0,0 +1,244 @@
import React, { useEffect } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, StyleSheet,
} 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 { useAuthStore } from '@store/authStore';
import { ColorSwatch } from '@presentation/components/filament/ColorSwatch';
import { StockBar, StockBadge } from '@presentation/components/filament/StockBar';
import { Card } from '@presentation/components/ui/Card';
import { calcFilamentPercentage } from '@domain/Filament';
import { formatWeight } from '@shared/utils/filament';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela Home / Dashboard — 1-0
*
* Seções:
* - Totais (estoque total em kg, rolos acabando)
* - Uso Recente (2 últimos filamentos, cards grandes)
* - Inventário rápido (lista com botão "Pesar")
* - Por Material (resumo agregado)
* - Estoque Baixo (filamentos ≤35%)
*/
export default function HomeScreen(): React.ReactElement {
const router = useRouter();
const { user } = useAuthStore();
const { filaments, isLoading } = useFilamentStore();
// Dados derivados
const totalKg = (filaments.reduce((s, f) => s + f.netWeightG, 0) / 1000).toFixed(1);
const lowStockFilaments = filaments.filter((f) => calcFilamentPercentage(f) <= 35);
const recentFilaments = [...filaments].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
).slice(0, 2);
// Agrupamento por material
const byMaterial = filaments.reduce<Record<string, { count: number; totalG: number }>>((acc, f) => {
if (!acc[f.material]) acc[f.material] = { count: 0, totalG: 0 };
acc[f.material].count += 1;
acc[f.material].totalG += f.netWeightG;
return acc;
}, {});
return (
<SafeAreaView style={styles.safe}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
{/* Cabeçalho */}
<View style={styles.headerRow}>
<View>
<Text style={styles.welcomeText}>Bem-vindo de volta,</Text>
<Text style={styles.title}>Seu Inventário</Text>
</View>
<TouchableOpacity
style={styles.qrBtn}
onPress={() => router.push('/(app)/qrcode/scan')}
>
<Ionicons name="qr-code-outline" size={22} color={colors.textPrimary} />
</TouchableOpacity>
</View>
{/* Totais */}
<View style={styles.statsRow}>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Total em Estoque</Text>
<Text style={styles.statValue}>{totalKg} kg</Text>
</Card>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Rolos Acabando</Text>
<Text style={styles.statValueAlert}>{lowStockFilaments.length} unidades</Text>
</Card>
</View>
{/* Uso Recente */}
{recentFilaments.length > 0 && (
<>
<Text style={styles.sectionTitle}>USO RECENTE</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.recentScroll}>
{recentFilaments.map((f) => {
const pct = calcFilamentPercentage(f);
return (
<TouchableOpacity
key={f.id}
style={styles.recentCard}
onPress={() => router.push(`/(app)/inventory/${f.id}`)}
activeOpacity={0.8}
>
<ColorSwatch colorHex={f.colorHex} size={40} />
<Text style={styles.recentName}>{f.model ?? f.material}</Text>
<Text style={styles.recentMeta}>{f.brand} · {f.colorHex}</Text>
<View style={styles.recentBottom}>
<Text style={styles.recentWeight}>{formatWeight(f.netWeightG)}</Text>
<StockBadge percentage={pct} />
</View>
<StockBar percentage={pct} />
</TouchableOpacity>
);
})}
</ScrollView>
</>
)}
{/* Inventário rápido */}
{filaments.length > 0 && (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>INVENTÁRIO</Text>
<TouchableOpacity onPress={() => router.push('/(app)/(tabs)/inventory')}>
<Text style={styles.sectionLink}>Ver todos</Text>
</TouchableOpacity>
</View>
{filaments.slice(0, 4).map((f) => (
<TouchableOpacity
key={f.id}
style={styles.inventoryRow}
onPress={() => router.push(`/(app)/inventory/${f.id}`)}
activeOpacity={0.8}
>
<ColorSwatch colorHex={f.colorHex} size={40} />
<View style={styles.inventoryInfo}>
<Text style={styles.inventoryName}>{f.model ?? f.material}</Text>
<Text style={styles.inventoryMeta}>
{f.tempHotendC ? `${f.tempHotendC}°C` : '—'} / {f.tempBedC ? `${f.tempBedC}°C` : '—'} · Fluxo {f.flowFactorPct ?? '—'}
</Text>
</View>
<View style={styles.inventoryRight}>
<Text style={styles.inventoryWeight}>{formatWeight(f.netWeightG)}</Text>
<TouchableOpacity style={styles.pesarBtn}>
<Ionicons name="scale-outline" size={14} color={colors.accent} />
<Text style={styles.pesarText}>Pesar</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
))}
</>
)}
{/* Por Material */}
{Object.keys(byMaterial).length > 0 && (
<>
<Text style={styles.sectionTitle}>POR MATERIAL</Text>
{Object.entries(byMaterial).map(([mat, data]) => (
<View key={mat} style={styles.materialRow}>
<View style={[styles.materialDot, { backgroundColor: colors.accent }]} />
<Text style={styles.materialName}>{mat}</Text>
<Text style={styles.materialCount}>{data.count} rolos</Text>
<Text style={styles.materialWeight}>{(data.totalG / 1000).toFixed(1)} kg</Text>
</View>
))}
</>
)}
{/* Estoque Baixo */}
{lowStockFilaments.length > 0 && (
<>
<Text style={styles.sectionTitle}>ESTOQUE BAIXO</Text>
{lowStockFilaments.map((f) => {
const pct = calcFilamentPercentage(f);
return (
<TouchableOpacity
key={f.id}
style={styles.lowStockCard}
onPress={() => router.push(`/(app)/inventory/${f.id}`)}
activeOpacity={0.8}
>
<View style={[styles.lowStockAccent, { backgroundColor: pct <= 15 ? colors.stockLow : colors.stockMedium }]} />
<ColorSwatch colorHex={f.colorHex} size={40} />
<View style={styles.lowStockInfo}>
<Text style={styles.lowStockName}>{f.model ?? f.material}</Text>
<Text style={styles.lowStockMeta}>{f.brand} · {f.material}</Text>
</View>
<View style={styles.lowStockRight}>
<Text style={[styles.lowStockWeight, { color: pct <= 15 ? colors.stockLow : colors.stockMedium }]}>
{formatWeight(f.netWeightG)}
</Text>
<StockBadge percentage={pct} />
</View>
</TouchableOpacity>
);
})}
</>
)}
{/* Estado vazio */}
{!isLoading && filaments.length === 0 && (
<View style={styles.empty}>
<Ionicons name="layers-outline" size={48} color={colors.textSecondary} />
<Text style={styles.emptyTitle}>Nenhum filamento ainda</Text>
<Text style={styles.emptyText}>Toque em + para adicionar seu primeiro filamento.</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
scroll: { padding: spacing[5], gap: spacing[4], paddingBottom: spacing[10] },
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: spacing[2] },
welcomeText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
title: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
qrBtn: { width: 40, height: 40, borderRadius: radius.md, backgroundColor: colors.bgSurface, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border },
statsRow: { flexDirection: 'row', gap: spacing[3] },
statCard: { flex: 1, gap: spacing[1] },
statLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
statValue: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xl, fontWeight: typography.fontWeight.bold, color: colors.accent },
statValueAlert: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xl, fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
sectionTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.bold, color: colors.textSecondary, letterSpacing: 0.8, textTransform: 'uppercase', marginTop: spacing[2] },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: spacing[2] },
sectionLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.accent },
recentScroll: { marginHorizontal: -spacing[5] },
recentCard: { width: 160, backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[4], marginLeft: spacing[5], gap: spacing[2], borderWidth: 1, borderColor: colors.border },
recentName: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
recentMeta: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
recentBottom: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
recentWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.lg, fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
inventoryRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[4], borderWidth: 1, borderColor: colors.border },
inventoryInfo: { flex: 1, gap: 2 },
inventoryName: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
inventoryMeta: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
inventoryRight: { alignItems: 'flex-end', gap: spacing[1] },
inventoryWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
pesarBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
pesarText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.accent },
materialRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingVertical: spacing[3], borderBottomWidth: 1, borderBottomColor: colors.border },
materialDot: { width: 8, height: 8, borderRadius: radius.full },
materialName: { flex: 1, fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textPrimary },
materialCount: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
materialWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.bold, color: colors.textPrimary, minWidth: 60, textAlign: 'right' },
lowStockCard: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[4], borderWidth: 1, borderColor: colors.border, overflow: 'hidden' },
lowStockAccent: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 3 },
lowStockInfo: { flex: 1, gap: 2 },
lowStockName: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
lowStockMeta: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
lowStockRight: { alignItems: 'flex-end', gap: spacing[1] },
lowStockWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.bold },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing[3], marginTop: spacing[16] },
emptyTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.lg, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
emptyText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary, textAlign: 'center' },
});
+132
View File
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import {
View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput,
} 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 { FilamentCard } from '@presentation/components/filament/FilamentCard';
import type { Filament } from '@domain/Filament';
import { colors, typography, spacing, radius } from '@shared/theme';
import { MATERIALS } from '@shared/constants';
const TABS = ['Todos', ...MATERIALS.slice(0, 4)] as const;
/**
* Tela de Inventário — CW-0
* Busca, chips de material, lista de filamentos com peso e % de estoque.
*/
export default function InventoryScreen(): React.ReactElement {
const router = useRouter();
const { filaments } = useFilamentStore();
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<string>('Todos');
const filtered = filaments.filter((f) => {
const matchMaterial = activeTab === 'Todos' || f.material === activeTab;
const matchSearch =
search.length === 0 ||
f.brand.toLowerCase().includes(search.toLowerCase()) ||
(f.model ?? '').toLowerCase().includes(search.toLowerCase()) ||
f.material.toLowerCase().includes(search.toLowerCase());
return matchMaterial && matchSearch;
});
function renderItem({ item }: { item: Filament }): React.ReactElement {
return <FilamentCard filament={item} />;
}
return (
<SafeAreaView style={styles.safe}>
{/* Cabeçalho */}
<View style={styles.header}>
<View>
<Text style={styles.headerSub}>Seu estoque</Text>
<Text style={styles.headerTitle}>Inventário</Text>
</View>
<TouchableOpacity
style={styles.filterBtn}
onPress={() => router.push('/(app)/inventory/filters')}
>
<Ionicons name="options-outline" size={20} color={colors.textPrimary} />
</TouchableOpacity>
</View>
{/* Busca */}
<View style={styles.searchRow}>
<Ionicons name="search-outline" size={18} color={colors.textSecondary} style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="Buscar por marca, material..."
placeholderTextColor={colors.textSecondary}
value={search}
onChangeText={setSearch}
/>
</View>
{/* Chips de material */}
<View style={styles.chipsRow}>
{TABS.map((tab) => (
<TouchableOpacity
key={tab}
onPress={() => setActiveTab(tab)}
style={[styles.chip, activeTab === tab && styles.chipActive]}
>
<Text style={[styles.chipText, activeTab === tab && styles.chipTextActive]}>
{tab}
</Text>
</TouchableOpacity>
))}
</View>
{/* Contagem */}
<View style={styles.countRow}>
<Text style={styles.countText}>{filtered.length} FILAMENTOS</Text>
<TouchableOpacity style={styles.sortBtn}>
<Ionicons name="swap-vertical-outline" size={14} color={colors.textSecondary} />
<Text style={styles.sortText}>Ordenar</Text>
</TouchableOpacity>
</View>
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="layers-outline" size={40} color={colors.textSecondary} />
<Text style={styles.emptyText}>Nenhum filamento encontrado.</Text>
</View>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingHorizontal: spacing[5], paddingTop: spacing[4] },
headerSub: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
headerTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
filterBtn: { width: 40, height: 40, borderRadius: radius.md, backgroundColor: colors.bgSurface, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border },
searchRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.bgSurface, borderRadius: radius.md, marginHorizontal: spacing[5], marginTop: spacing[4], paddingHorizontal: spacing[4], borderWidth: 1, borderColor: colors.border, height: 48 },
searchIcon: { marginRight: spacing[2] },
searchInput: { flex: 1, fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textPrimary },
chipsRow: { flexDirection: 'row', gap: spacing[2], paddingHorizontal: spacing[5], marginTop: spacing[3], flexWrap: 'wrap' },
chip: { paddingHorizontal: spacing[4], paddingVertical: spacing[2], borderRadius: radius.full, backgroundColor: colors.bgSurface, borderWidth: 1, borderColor: colors.border },
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.medium, color: colors.textSecondary },
chipTextActive: { color: colors.bgBase },
countRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: spacing[5], marginTop: spacing[4], marginBottom: spacing[2] },
countText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.bold, color: colors.textSecondary, letterSpacing: 0.8 },
sortBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
sortText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
list: { paddingHorizontal: spacing[5], paddingBottom: spacing[10] },
separator: { height: spacing[2] },
empty: { alignItems: 'center', gap: spacing[3], paddingTop: spacing[12] },
emptyText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
});
+234
View File
@@ -0,0 +1,234 @@
import React from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuthStore } from '@store/authStore';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela de Perfil — 1LQ-0
*/
export default function ProfileScreen(): React.ReactElement {
const router = useRouter();
const { user, clearSession } = useAuthStore();
function handleLogout(): void {
Alert.alert(
'Sair da conta',
'Deseja sair da sua conta? Seus dados offline continuarão disponíveis.',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Sair',
style: 'destructive',
onPress: async () => {
await clearSession();
router.replace('/(auth)/login');
},
},
],
);
}
const displayName = user?.name ?? 'Usuário';
const displayEmail = user?.email ?? 'email@exemplo.com';
const isGoogleLinked = user?.googleId != null;
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Perfil</Text>
</View>
<View style={styles.content}>
{/* Avatar */}
<View style={styles.avatarSection}>
<View style={styles.avatarRing}>
<Ionicons name="person-outline" size={40} color={colors.accent} />
</View>
<Text style={styles.userName}>{displayName}</Text>
<Text style={styles.userEmail}>{displayEmail}</Text>
{isGoogleLinked && (
<View style={styles.googleBadge}>
<Ionicons name="logo-google" size={14} color={colors.textPrimary} />
<Text style={styles.googleBadgeText}>Conectado com Google</Text>
</View>
)}
</View>
{/* Conta */}
<Text style={styles.sectionLabel}>CONTA</Text>
<View style={styles.menuGroup}>
<TouchableOpacity style={[styles.menuItem, styles.menuItemFirst]}>
<Ionicons name="mail-outline" size={20} color={colors.textSecondary} />
<View style={styles.menuItemContent}>
<Text style={styles.menuItemLabel}>E-mail</Text>
<Text style={styles.menuItemValue}>{displayEmail}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
<View style={styles.menuDivider} />
<TouchableOpacity style={[styles.menuItem, styles.menuItemLast]}>
<Ionicons name="lock-closed-outline" size={20} color={colors.textSecondary} />
<View style={styles.menuItemContent}>
<Text style={styles.menuItemLabel}>Senha</Text>
<Text style={styles.menuItemValue}></Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Vinculações */}
<Text style={styles.sectionLabel}>VINCULAÇÕES</Text>
<View style={styles.menuGroup}>
<View style={[styles.menuItem, styles.menuItemFirst, styles.menuItemLast]}>
<Ionicons name="logo-google" size={20} color={colors.textSecondary} />
<View style={styles.menuItemContent}>
<Text style={styles.menuItemLabel}>Google</Text>
<Text style={styles.menuItemValue}>{displayEmail}</Text>
</View>
<View style={[styles.linkedBadge, !isGoogleLinked && styles.unlinkedBadge]}>
<Text style={[styles.linkedBadgeText, !isGoogleLinked && styles.unlinkedBadgeText]}>
{isGoogleLinked ? 'Vinculado' : 'Vincular'}
</Text>
</View>
</View>
</View>
{/* Logout */}
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={18} color={colors.error} />
<Text style={styles.logoutText}>Sair da Conta</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
header: {
paddingHorizontal: spacing[5],
paddingTop: spacing[5],
paddingBottom: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
content: { flex: 1, paddingHorizontal: spacing[5], gap: spacing[5] },
avatarSection: { alignItems: 'center', gap: spacing[2], paddingBottom: spacing[2] },
avatarRing: {
width: 80,
height: 80,
borderRadius: radius.full,
borderWidth: 2,
borderColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgSurface,
},
userName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
userEmail: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
googleBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.bgSurface,
paddingHorizontal: spacing[3],
paddingVertical: 5,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginTop: spacing[1],
},
googleBadgeText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textPrimary,
},
sectionLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
menuGroup: {
backgroundColor: colors.bgSurface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
marginTop: -spacing[2],
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
padding: spacing[4],
},
menuItemFirst: {},
menuItemLast: {},
menuItemContent: { flex: 1 },
menuItemLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
marginBottom: 2,
},
menuItemValue: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textPrimary,
},
menuDivider: { height: 1, backgroundColor: colors.border, marginLeft: spacing[5] + 20 + spacing[3] },
linkedBadge: {
backgroundColor: colors.bgHover,
paddingHorizontal: spacing[3],
paddingVertical: 5,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
unlinkedBadge: { borderColor: colors.accent, backgroundColor: colors.accentMuted },
linkedBadgeText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
unlinkedBadgeText: { color: colors.accent },
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing[2],
backgroundColor: 'rgba(255,107,107,0.12)',
paddingVertical: spacing[4],
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(255,107,107,0.3)',
},
logoutText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.error,
},
});
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
import { Stack } from 'expo-router';
export default function AppLayout(): React.ReactElement {
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }} />
);
}
@@ -0,0 +1,249 @@
import React, { useState } from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, Alert,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
const schema = z.object({
name: z.string().min(1, 'Nome obrigatório'),
spoolWeightG: z.coerce.number().min(1, 'Peso obrigatório'),
});
type FormData = z.infer<typeof schema>;
type SpoolType = 'Plástico' | 'Papelão' | 'Outro';
/**
* Tela de Editar Preset — 1KD-0
*/
export default function EditPresetScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { presets, updatePreset, removePreset } = usePresetStore();
const preset = presets.find((p) => p.id === id);
const [spoolType, setSpoolType] = useState<SpoolType>('Plástico');
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: preset?.name ?? '',
spoolWeightG: preset?.spoolWeightG ?? undefined,
},
});
if (!preset) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Preset não encontrado</Text>
<TouchableOpacity onPress={() => router.back()}>
<Text style={styles.backLink}>Voltar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
function handleDelete(): void {
Alert.alert(
'Excluir preset',
`Deseja excluir o preset "${preset!.name}"?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Excluir',
style: 'destructive',
onPress: () => {
removePreset(preset!.id);
router.replace('/(app)/(tabs)/config' as never);
},
},
],
);
}
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: UpdatePresetUseCase via DI container
updatePreset({
...preset!,
name: data.name,
spoolWeightG: Number(data.spoolWeightG),
});
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar as alterações.');
} finally {
setIsLoading(false);
}
}
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Editar Preset</Text>
<TouchableOpacity onPress={handleDelete}>
<Ionicons name="trash-outline" size={20} color={colors.textPrimary} />
</TouchableOpacity>
</View>
<View style={styles.content}>
{/* Ícone */}
<View style={styles.iconContainer}>
<View style={styles.iconBg}>
<Ionicons name="disc-outline" size={48} color={colors.accent} />
</View>
</View>
{/* Nome */}
<Controller
control={control}
name="name"
render={({ field }) => (
<Input
label="NOME DO PRESET"
placeholder="Ex: Minha Marca Especial"
error={errors.name?.message}
onChangeText={field.onChange}
value={field.value}
leftIcon={<Ionicons name="create-outline" size={16} color={colors.textSecondary} />}
/>
)}
/>
{/* Peso */}
<Controller
control={control}
name="spoolWeightG"
render={({ field }) => (
<Input
label="PESO DO CARRETEL VAZIO"
placeholder="Ex: 250"
keyboardType="numeric"
error={errors.spoolWeightG?.message}
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="scale-outline" size={16} color={colors.textSecondary} />}
rightLabel="g"
/>
)}
/>
<Text style={styles.hint}>
Pese o carretel vazio em uma balança e insira o valor em gramas.
</Text>
{/* Tipo */}
<View style={styles.typeRow}>
<Text style={styles.typeLabel}>TIPO DE CARRETEL</Text>
<Text style={styles.optional}>Opcional</Text>
</View>
<View style={styles.typeChips}>
{(['Plástico', 'Papelão', 'Outro'] as SpoolType[]).map((t) => (
<TouchableOpacity
key={t}
onPress={() => setSpoolType(t)}
style={[styles.chip, spoolType === t && styles.chipActive]}
>
<Text style={[styles.chipText, spoolType === t && styles.chipTextActive]}>{t}</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* CTA */}
<View style={styles.footer}>
<Button label="Salvar Alterações" onPress={handleSubmit(onSubmit)} isLoading={isLoading} />
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing[4] },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
backLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.accent },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
content: { flex: 1, paddingHorizontal: spacing[5], gap: spacing[4] },
iconContainer: { alignItems: 'center', paddingVertical: spacing[6] },
iconBg: {
width: 96,
height: 96,
borderRadius: radius.xl,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
},
hint: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
lineHeight: typography.fontSize.sm * 1.6,
marginTop: -spacing[2],
},
typeRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
typeLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
optional: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
typeChips: { flexDirection: 'row', gap: spacing[2] },
chip: {
paddingHorizontal: spacing[4],
paddingVertical: spacing[3],
borderRadius: radius.md,
backgroundColor: colors.bgSurface,
borderWidth: 1,
borderColor: colors.border,
},
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.textSecondary,
},
chipTextActive: { color: colors.bgBase },
footer: {
padding: spacing[5],
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.bgBase,
},
});
+212
View File
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
import type { SpoolPreset } from '@domain/SpoolPreset';
const schema = z.object({
name: z.string().min(1, 'Nome obrigatório'),
spoolWeightG: z.coerce.number().min(1, 'Peso obrigatório'),
});
type FormData = z.infer<typeof schema>;
type SpoolType = 'Plástico' | 'Papelão' | 'Outro';
/**
* Tela de Novo Preset — QM-0
*/
export default function NewPresetScreen(): React.ReactElement {
const router = useRouter();
const { addPreset } = usePresetStore();
const [spoolType, setSpoolType] = useState<SpoolType>('Plástico');
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '', spoolWeightG: undefined },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: CreatePresetUseCase via DI container
const preset: SpoolPreset = {
id: `user-${Date.now()}`,
name: data.name,
spoolWeightG: Number(data.spoolWeightG),
isSystem: false,
userId: 'me',
createdAt: new Date().toISOString(),
};
addPreset(preset);
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar o preset.');
} finally {
setIsLoading(false);
}
}
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Novo Preset</Text>
<View style={{ width: 24 }} />
</View>
<View style={styles.content}>
{/* Ícone */}
<View style={styles.iconContainer}>
<View style={styles.iconBg}>
<Ionicons name="disc-outline" size={48} color={colors.accent} />
</View>
</View>
{/* Nome */}
<Controller
control={control}
name="name"
render={({ field }) => (
<Input
label="NOME DO PRESET"
placeholder="Ex: Minha Marca Especial"
error={errors.name?.message}
onChangeText={field.onChange}
value={field.value}
leftIcon={<Ionicons name="create-outline" size={16} color={colors.textSecondary} />}
/>
)}
/>
{/* Peso */}
<Controller
control={control}
name="spoolWeightG"
render={({ field }) => (
<Input
label="PESO DO CARRETEL VAZIO"
placeholder="Ex: 250"
keyboardType="numeric"
error={errors.spoolWeightG?.message}
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="scale-outline" size={16} color={colors.textSecondary} />}
rightLabel="g"
/>
)}
/>
<Text style={styles.hint}>
Pese o carretel vazio em uma balança e insira o valor em gramas.
</Text>
{/* Tipo */}
<View style={styles.typeRow}>
<Text style={styles.typeLabel}>TIPO DE CARRETEL</Text>
<Text style={styles.optional}>Opcional</Text>
</View>
<View style={styles.typeChips}>
{(['Plástico', 'Papelão', 'Outro'] as SpoolType[]).map((t) => (
<TouchableOpacity
key={t}
onPress={() => setSpoolType(t)}
style={[styles.chip, spoolType === t && styles.chipActive]}
>
<Text style={[styles.chipText, spoolType === t && styles.chipTextActive]}>{t}</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* CTA */}
<View style={styles.footer}>
<Button label="Salvar Preset" onPress={handleSubmit(onSubmit)} isLoading={isLoading} />
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
content: { flex: 1, paddingHorizontal: spacing[5], gap: spacing[4] },
iconContainer: { alignItems: 'center', paddingVertical: spacing[6] },
iconBg: {
width: 96,
height: 96,
borderRadius: radius.xl,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
},
hint: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
lineHeight: typography.fontSize.sm * 1.6,
marginTop: -spacing[2],
},
typeRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
typeLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
optional: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
typeChips: { flexDirection: 'row', gap: spacing[2] },
chip: {
paddingHorizontal: spacing[4],
paddingVertical: spacing[3],
borderRadius: radius.md,
backgroundColor: colors.bgSurface,
borderWidth: 1,
borderColor: colors.border,
},
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.textSecondary,
},
chipTextActive: { color: colors.bgBase },
footer: {
padding: spacing[5],
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.bgBase,
},
});
+307
View File
@@ -0,0 +1,307 @@
import React, { useState } from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, Alert,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useFilamentStore } from '@store/filamentStore';
import { usePresetStore } from '@store/presetStore';
import { calcFilamentPercentage } from '@domain/Filament';
import { colors, typography, spacing, radius } from '@shared/theme';
import { formatWeight } from '@shared/utils/filament';
import { Button } from '@presentation/components/ui/Button';
type LabelSize = '50x30' | '62x29' | '38x25';
const LABEL_SIZES: { id: LabelSize; label: string; sub: string }[] = [
{ id: '50x30', label: '50 × 30', sub: 'Padrão' },
{ id: '62x29', label: '62 × 29', sub: 'Brother DK' },
{ id: '38x25', label: '38 × 25', sub: 'Dymo 11354' },
];
type ContentOption = 'color' | 'name' | 'material_brand' | 'net_weight' | 'print_temp' | 'qrcode';
const CONTENT_OPTIONS: { id: ContentOption; label: string }[] = [
{ id: 'color', label: 'Cor visual' },
{ id: 'name', label: 'Nome' },
{ id: 'material_brand', label: 'Material · Marca' },
{ id: 'net_weight', label: 'Peso líquido' },
{ id: 'print_temp', label: 'Temp. impressão' },
{ id: 'qrcode', label: 'QR Code' },
];
/**
* Tela de Exportar Etiqueta — 1FZ-0
*/
export default function LabelScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { filaments } = useFilamentStore();
const { presets } = usePresetStore();
const filament = filaments.find((f) => f.id === id);
const preset = filament ? presets.find((p) => p.id === filament.spoolPresetId) : undefined;
const [selectedSize, setSelectedSize] = useState<LabelSize>('50x30');
const [enabledContent, setEnabledContent] = useState<Set<ContentOption>>(
new Set(['color', 'name', 'material_brand', 'net_weight', 'print_temp', 'qrcode']),
);
if (!filament) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Filamento não encontrado</Text>
</View>
</SafeAreaView>
);
}
const pct = calcFilamentPercentage(filament);
function toggleContent(opt: ContentOption): void {
setEnabledContent((prev) => {
const next = new Set(prev);
if (next.has(opt)) next.delete(opt); else next.add(opt);
return next;
});
}
function handleExport(): void {
// TODO: Generate SVG and share via expo-sharing
Alert.alert('Exportar SVG', 'Funcionalidade de exportação SVG será implementada na integração com o backend.');
}
const sizeLabel = LABEL_SIZES.find((s) => s.id === selectedSize);
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Exportar Etiqueta</Text>
<View style={{ width: 24 }} />
</View>
<View style={styles.content}>
{/* Label Preview */}
<Text style={styles.sectionLabel}>PREVIEW DA ETIQUETA</Text>
<View style={styles.previewCard}>
<View style={[styles.previewColorBar, { backgroundColor: filament.colorHex }]} />
<View style={styles.previewBody}>
<View style={styles.previewTop}>
<View style={styles.previewInfo}>
<View style={styles.previewNameRow}>
<View style={[styles.previewDot, { backgroundColor: filament.colorHex }]} />
<Text style={styles.previewName}>
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
</Text>
</View>
<Text style={styles.previewMeta}>
{filament.brand} · {filament.material} · {filament.colorHex}
</Text>
<View style={styles.previewParamsRow}>
<View>
<Text style={styles.previewParamLabel}>DISPONÍVEL</Text>
<Text style={styles.previewParamValue}>{formatWeight(filament.netWeightG)}</Text>
</View>
{filament.tempHotendC && (
<View>
<Text style={styles.previewParamLabel}>HOTEND</Text>
<Text style={styles.previewParamValue}>{filament.tempHotendC}°C</Text>
</View>
)}
{filament.tempBedC && (
<View>
<Text style={styles.previewParamLabel}>MESA</Text>
<Text style={styles.previewParamValue}>{filament.tempBedC}°C</Text>
</View>
)}
</View>
</View>
{/* QR placeholder */}
<Ionicons name="qr-code" size={48} color={colors.textSecondary} />
</View>
</View>
</View>
<Text style={styles.previewSize}>
<Ionicons name="resize-outline" size={12} color={colors.textSecondary} />{' '}
{sizeLabel?.label} mm ({sizeLabel?.sub})
</Text>
{/* Tamanho */}
<Text style={[styles.sectionLabel, { marginTop: spacing[2] }]}>TAMANHO</Text>
<View style={styles.sizeChips}>
{LABEL_SIZES.map((s) => (
<TouchableOpacity
key={s.id}
onPress={() => setSelectedSize(s.id)}
style={[styles.sizeChip, selectedSize === s.id && styles.sizeChipActive]}
>
<Text style={[styles.sizeChipMain, selectedSize === s.id && styles.sizeChipMainActive]}>
{s.label}
</Text>
<Text style={[styles.sizeChipSub, selectedSize === s.id && styles.sizeChipSubActive]}>
{s.sub}
</Text>
</TouchableOpacity>
))}
</View>
{/* Conteúdo */}
<Text style={styles.sectionLabel}>CONTEÚDO DA ETIQUETA</Text>
<View style={styles.contentOptions}>
{CONTENT_OPTIONS.map((opt) => {
const active = enabledContent.has(opt.id);
return (
<TouchableOpacity
key={opt.id}
onPress={() => toggleContent(opt.id)}
style={[styles.contentChip, active && styles.contentChipActive]}
>
{active && <Ionicons name="checkmark" size={12} color={colors.accent} />}
<Text style={[styles.contentChipText, active && styles.contentChipTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* CTA */}
<View style={styles.footer}>
<Button
label="Exportar SVG"
leftIcon={<Ionicons name="download-outline" size={18} color={colors.bgBase} />}
onPress={handleExport}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center' },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
content: { flex: 1, paddingHorizontal: spacing[5], gap: spacing[4] },
sectionLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
previewCard: {
backgroundColor: '#F5EFE0',
borderRadius: radius.lg,
flexDirection: 'row',
overflow: 'hidden',
minHeight: 100,
},
previewColorBar: { width: 10 },
previewBody: { flex: 1, padding: spacing[3] },
previewTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' },
previewInfo: { flex: 1, gap: 4 },
previewNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
previewDot: { width: 12, height: 12, borderRadius: radius.full },
previewName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.black,
},
previewMeta: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: '#666',
},
previewParamsRow: { flexDirection: 'row', gap: spacing[4], marginTop: 4 },
previewParamLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: 9,
color: '#888',
textTransform: 'uppercase',
},
previewParamValue: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.black,
},
previewSize: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
textAlign: 'right',
marginTop: -spacing[2],
},
sizeChips: { flexDirection: 'row', gap: spacing[2] },
sizeChip: {
flex: 1,
alignItems: 'center',
paddingVertical: spacing[3],
borderRadius: radius.lg,
backgroundColor: colors.bgSurface,
borderWidth: 1,
borderColor: colors.border,
},
sizeChipActive: { borderColor: colors.accent, backgroundColor: colors.accentMuted },
sizeChipMain: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
},
sizeChipMainActive: { color: colors.accent },
sizeChipSub: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
marginTop: 2,
},
sizeChipSubActive: { color: colors.accent },
contentOptions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
contentChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing[3],
paddingVertical: spacing[2],
borderRadius: radius.full,
backgroundColor: colors.bgHover,
borderWidth: 1,
borderColor: colors.border,
},
contentChipActive: { borderColor: colors.accent },
contentChipText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
contentChipTextActive: { color: colors.accent },
footer: {
padding: spacing[5],
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.bgBase,
},
});
+189
View File
@@ -0,0 +1,189 @@
import React from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, Alert, Share,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useFilamentStore } from '@store/filamentStore';
import { colors, typography, spacing, radius } from '@shared/theme';
import { Button } from '@presentation/components/ui/Button';
/**
* Tela de QR Code — 1CF-0
*/
export default function QRCodeScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { filaments } = useFilamentStore();
const filament = filaments.find((f) => f.id === id);
const slug = filament
? `${filament.brand.toLowerCase()}-${(filament.model ?? '').toLowerCase()}-${filament.colorHex.replace('#', '').toLowerCase()}`.replace(/\s+/g, '-')
: 'desconhecido';
const deepLink = `meowspool.app/f/${slug}`;
async function handleShare(): Promise<void> {
try {
await Share.share({ message: `meowspool://filament/${id}`, url: `https://${deepLink}` });
} catch {
// cancelled
}
}
async function handleCopyLink(): Promise<void> {
// expo-clipboard not installed — show alert as placeholder
Alert.alert('Link copiado', deepLink);
}
if (!filament) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Filamento não encontrado</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safe}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>QR Code</Text>
<View style={{ width: 24 }} />
</View>
{/* Filament identity */}
<View style={styles.identity}>
<View style={[styles.swatch, { backgroundColor: filament.colorHex }]} />
<View>
<Text style={styles.name}>
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
</Text>
<Text style={styles.meta}>
{filament.brand} · {filament.material} · {filament.colorHex}
</Text>
</View>
</View>
{/* QR Code area */}
<View style={styles.qrContainer}>
<View style={styles.qrCard}>
{/* Placeholder QR — real impl would use react-native-qrcode-svg */}
<View style={styles.qrPlaceholder}>
<Ionicons name="qr-code" size={160} color={colors.black} />
</View>
</View>
<Text style={styles.qrHint}>Aponte a câmera para escanear</Text>
<View style={styles.linkBadge}>
<Text style={styles.linkText}>{deepLink}</Text>
</View>
</View>
{/* Actions */}
<View style={styles.footer}>
<Button
label="Compartilhar QR Code"
leftIcon={<Ionicons name="share-outline" size={18} color={colors.bgBase} />}
onPress={handleShare}
/>
<TouchableOpacity style={styles.copyBtn} onPress={handleCopyLink}>
<Text style={styles.copyBtnText}>Copiar link</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center' },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
identity: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
paddingHorizontal: spacing[5],
paddingBottom: spacing[4],
},
swatch: { width: 44, height: 44, borderRadius: radius.md },
name: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
meta: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
qrContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[4],
paddingHorizontal: spacing[5],
},
qrCard: {
backgroundColor: '#F5EFE0',
borderRadius: radius.xl,
padding: spacing[6],
},
qrPlaceholder: {
width: 200,
height: 200,
alignItems: 'center',
justifyContent: 'center',
},
qrHint: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
linkBadge: {
backgroundColor: colors.bgSurface,
paddingHorizontal: spacing[4],
paddingVertical: spacing[2],
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
},
linkText: {
fontFamily: typography.fontFamily.mono,
fontSize: typography.fontSize.xs,
color: colors.accent,
},
footer: {
padding: spacing[5],
gap: spacing[3],
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.bgBase,
},
copyBtn: { alignItems: 'center', paddingVertical: spacing[2] },
copyBtnText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
},
});
+351
View File
@@ -0,0 +1,351 @@
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, Share,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useFilamentStore } from '@store/filamentStore';
import { usePresetStore } from '@store/presetStore';
import { calcFilamentPercentage } from '@domain/Filament';
import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme';
import { formatWeight } from '@shared/utils/filament';
import { Card } from '@presentation/components/ui/Card';
import { Button } from '@presentation/components/ui/Button';
import { StockBar } from '@presentation/components/filament/StockBar';
/**
* Tela de Detalhe do Filamento — 6L-0
*/
export default function FilamentDetailScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { filaments, removeFilament } = useFilamentStore();
const { presets } = usePresetStore();
const filament = filaments.find((f) => f.id === id);
const preset = filament ? presets.find((p) => p.id === filament.spoolPresetId) : undefined;
if (!filament) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Filamento não encontrado</Text>
<TouchableOpacity onPress={() => router.back()}>
<Text style={styles.backLink}>Voltar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const pct = calcFilamentPercentage(filament);
const stockColor = getStockColor(pct);
const slug = `${filament.brand.toLowerCase()}-${(filament.model ?? '').toLowerCase()}-${filament.colorHex.replace('#', '').toLowerCase()}`.replace(/\s+/g, '-');
function handleDelete(): void {
Alert.alert(
'Excluir filamento',
`Deseja excluir "${filament!.brand}${filament!.model ? ` ${filament!.model}` : ''}"? Esta ação não pode ser desfeita.`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Excluir',
style: 'destructive',
onPress: () => {
removeFilament(filament!.id);
router.back();
},
},
],
);
}
return (
<SafeAreaView style={styles.safe}>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
</Text>
<View style={styles.headerActions}>
<TouchableOpacity
onPress={() => router.push(`/(app)/inventory/${id}/edit` as never)}
style={styles.headerBtn}
>
<Ionicons name="pencil-outline" size={20} color={colors.textPrimary} />
</TouchableOpacity>
<TouchableOpacity onPress={handleDelete} style={styles.headerBtn}>
<Ionicons name="trash-outline" size={20} color={colors.textPrimary} />
</TouchableOpacity>
</View>
</View>
<View style={styles.content}>
{/* Identity Card */}
<Card style={styles.identityCard}>
<View style={[styles.colorSwatch, { backgroundColor: filament.colorHex }]} />
<View style={styles.identityInfo}>
<Text style={styles.filamentName}>
{filament.brand}{filament.model ? ` ${filament.model}` : ''}
</Text>
<Text style={styles.filamentBrandLine}>
{filament.brand} · {filament.colorHex.toLowerCase().startsWith('#') ? '' : '#'}{filament.colorHex}
</Text>
<View style={styles.badgeRow}>
<View style={styles.materialBadge}>
<Text style={styles.materialBadgeText}>{filament.material}</Text>
</View>
<View style={styles.hexBadge}>
<Text style={styles.hexBadgeText}>{filament.colorHex}</Text>
</View>
</View>
</View>
</Card>
{/* Filamento Disponível */}
<Card>
<View style={styles.stockHeader}>
<Text style={styles.sectionLabel}>FILAMENTO DISPONÍVEL</Text>
<View style={[styles.pctBadge, { backgroundColor: getStockBgColor(pct) }]}>
<Text style={[styles.pctText, { color: stockColor }]}>{pct}%</Text>
</View>
</View>
<View style={styles.weightRow}>
<Text style={[styles.netWeight, { color: stockColor }]}>
{formatWeight(filament.netWeightG)}
</Text>
<Text style={styles.totalWeight}>de {formatWeight(filament.totalWeightG)}</Text>
</View>
<StockBar percentage={pct} />
<View style={styles.stockMeta}>
<Text style={styles.stockMetaText}>
Carretel: {preset?.name ?? '—'} · {preset?.spoolWeightG ?? 0}g
</Text>
<Text style={styles.stockMetaText}>
Total pesado: {formatWeight(filament.totalWeightG)}
</Text>
</View>
</Card>
{/* Parâmetros de Impressão */}
<Card>
<Text style={styles.sectionLabel}>PARÂMETROS DE IMPRESSÃO</Text>
<View style={styles.paramsGrid}>
<View style={styles.paramItem}>
<Ionicons name="thermometer-outline" size={20} color={colors.stockLow} />
<Text style={styles.paramValue}>{filament.tempHotendC ?? '—'}°C</Text>
<Text style={styles.paramLabel}>Hotend</Text>
</View>
<View style={styles.paramItem}>
<Ionicons name="flag-outline" size={20} color={colors.stockMedium} />
<Text style={styles.paramValue}>{filament.tempBedC ?? '—'}°C</Text>
<Text style={styles.paramLabel}>Mesa</Text>
</View>
<View style={styles.paramItem}>
<Ionicons name="time-outline" size={20} color={colors.accent} />
<Text style={styles.paramValue}>{filament.flowFactorPct ?? '—'}%</Text>
<Text style={styles.paramLabel}>Fluxo</Text>
</View>
</View>
</Card>
{/* Identificação */}
<Card>
<View style={styles.idSection}>
{/* QR Code placeholder */}
<View style={styles.qrPreview}>
<Ionicons name="qr-code-outline" size={48} color={colors.textSecondary} />
</View>
<View style={styles.idInfo}>
<Text style={styles.idTitle}>Identificação</Text>
<Text style={styles.idSlug}>{slug}</Text>
<View style={styles.idButtons}>
<TouchableOpacity
style={styles.idBtn}
onPress={() => router.push(`/(app)/filaments/${id}/label` as never)}
>
<Ionicons name="add-outline" size={14} color={colors.accent} />
<Text style={styles.idBtnText}>Exportar SVG</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.idBtn, styles.idBtnSecondary]}
onPress={() => router.push(`/(app)/filaments/${id}/qrcode` as never)}
>
<Ionicons name="qr-code-outline" size={14} color={colors.textSecondary} />
<Text style={styles.idBtnTextSecondary}>Ver QR</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Card>
{/* Observações */}
{filament.notes ? (
<Card>
<Text style={styles.sectionLabel}>OBSERVAÇÕES</Text>
<Text style={styles.notesText}>{filament.notes}</Text>
</Card>
) : null}
</View>
</ScrollView>
{/* CTA fixo */}
<View style={styles.footer}>
<Button
label="Pesar Novamente"
leftIcon={<Ionicons name="scale-outline" size={18} color={colors.bgBase} />}
onPress={() => router.push(`/(app)/inventory/${id}/edit` as never)}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing[4] },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
backLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.accent },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
headerTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
flex: 1,
textAlign: 'center',
},
headerActions: { flexDirection: 'row', gap: spacing[2] },
headerBtn: { padding: spacing[1] },
content: { paddingHorizontal: spacing[5], gap: spacing[4], paddingBottom: spacing[10] },
identityCard: { flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
colorSwatch: { width: 72, height: 72, borderRadius: radius.lg },
identityInfo: { flex: 1, gap: spacing[1] },
filamentName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
filamentBrandLine: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
badgeRow: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[1] },
materialBadge: {
paddingHorizontal: spacing[3],
paddingVertical: 3,
backgroundColor: colors.bgHover,
borderRadius: radius.full,
},
materialBadgeText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.medium,
color: colors.textSecondary,
},
hexBadge: {
paddingHorizontal: spacing[3],
paddingVertical: 3,
backgroundColor: colors.bgHover,
borderRadius: radius.full,
},
hexBadgeText: {
fontFamily: typography.fontFamily.mono,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
sectionLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: spacing[3],
},
stockHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing[2] },
pctBadge: { paddingHorizontal: spacing[3], paddingVertical: 4, borderRadius: radius.sm },
pctText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold },
weightRow: { flexDirection: 'row', alignItems: 'baseline', gap: spacing[2], marginBottom: spacing[3] },
netWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['3xl'], fontWeight: typography.fontWeight.bold },
totalWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
stockMeta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing[3] },
stockMetaText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
paramsGrid: { flexDirection: 'row', justifyContent: 'space-around' },
paramItem: { alignItems: 'center', gap: spacing[1] },
paramValue: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
paramLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
idSection: { flexDirection: 'row', gap: spacing[4], alignItems: 'flex-start' },
qrPreview: {
width: 80,
height: 80,
backgroundColor: colors.bgHover,
borderRadius: radius.md,
alignItems: 'center',
justifyContent: 'center',
},
idInfo: { flex: 1, gap: spacing[2] },
idTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
idSlug: {
fontFamily: typography.fontFamily.mono,
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
idButtons: { flexDirection: 'row', gap: spacing[2], flexWrap: 'wrap' },
idBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: colors.accentMuted,
paddingHorizontal: spacing[3],
paddingVertical: 6,
borderRadius: radius.sm,
},
idBtnText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.accent },
idBtnSecondary: { backgroundColor: colors.bgHover },
idBtnTextSecondary: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
notesText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textPrimary,
lineHeight: typography.fontSize.base * typography.lineHeight.relaxed,
},
footer: {
padding: spacing[5],
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.bgBase,
},
});
+374
View File
@@ -0,0 +1,374 @@
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
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 { 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';
const schema = z.object({
brand: z.string().min(1, 'Marca obrigatória'),
model: z.string().optional(),
totalWeightG: z.coerce.number().min(1, 'Informe o peso total'),
tempHotendC: z.coerce.number().optional(),
tempBedC: z.coerce.number().optional(),
flowFactorPct: z.coerce.number().optional(),
notes: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
const PRESET_COLORS = ['#E05533', '#2563EB', '#FFFFFF', '#1E1B18', '#22C55E', '#EAB308', '#EC4899', '#A855F7', '#F97316'];
/**
* Tela de Edição de Filamento — 13O-0
*/
export default function EditFilamentScreen(): React.ReactElement {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { presets, systemPresets } = usePresetStore();
const { filaments, updateFilament, removeFilament } = useFilamentStore();
const filament = filaments.find((f) => f.id === id);
const [selectedColor, setSelectedColor] = useState(filament?.colorHex ?? '#E05533');
const [hexInput, setHexInput] = useState(filament?.colorHex ?? '#E05533');
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
brand: filament?.brand ?? '',
model: filament?.model ?? '',
totalWeightG: filament?.totalWeightG ?? 0,
tempHotendC: filament?.tempHotendC ?? 210,
tempBedC: filament?.tempBedC ?? 60,
flowFactorPct: filament?.flowFactorPct ?? 100,
notes: filament?.notes ?? '',
},
});
const totalWeight = watch('totalWeightG');
const selectedPreset = presets.find((p) => p.id === selectedPresetId);
const netWeight = selectedPreset && totalWeight
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
: 0;
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
if (!filament) {
return (
<SafeAreaView style={styles.safe}>
<View style={styles.notFound}>
<Text style={styles.notFoundText}>Filamento não encontrado</Text>
<TouchableOpacity onPress={() => router.back()}>
<Text style={styles.backLink}>Voltar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
function handleDelete(): void {
Alert.alert(
'Excluir filamento',
`Deseja excluir "${filament!.brand}${filament!.model ? ` ${filament!.model}` : ''}"? Esta ação não pode ser desfeita.`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Excluir',
style: 'destructive',
onPress: () => {
removeFilament(filament!.id);
router.replace('/(app)/(tabs)/inventory');
},
},
],
);
}
async function onSubmit(data: FormData): Promise<void> {
if (!selectedPresetId) {
Alert.alert('Atenção', 'Selecione um preset de carretel.');
return;
}
setIsLoading(true);
try {
// TODO: UpdateFilamentUseCase via container DI
const updated = {
...filament!,
brand: data.brand,
model: data.model ?? null,
colorHex: selectedColor,
material: selectedMaterial,
spoolPresetId: selectedPresetId,
totalWeightG: Number(data.totalWeightG),
netWeightG: netWeight,
tempHotendC: data.tempHotendC ?? null,
tempBedC: data.tempBedC ?? null,
flowFactorPct: data.flowFactorPct ?? null,
notes: data.notes ?? null,
updatedAt: new Date().toISOString(),
};
updateFilament(updated);
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar as alterações.');
} finally {
setIsLoading(false);
}
}
return (
<SafeAreaView style={styles.safe}>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Editar Filamento</Text>
<TouchableOpacity onPress={handleDelete}>
<Ionicons name="trash-outline" size={20} color={colors.textPrimary} />
</TouchableOpacity>
</View>
<View style={styles.content}>
{/* Cor */}
<Text style={styles.sectionLabel}>COR DO FILAMENTO</Text>
<Card style={styles.colorCard}>
<View style={styles.colorPreviewRow}>
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
<Text style={styles.hexValue}>{hexInput}</Text>
<TouchableOpacity><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
</View>
<View style={styles.colorPalette}>
{PRESET_COLORS.map((c) => (
<TouchableOpacity
key={c}
onPress={() => { setSelectedColor(c); setHexInput(c); }}
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
/>
))}
<TouchableOpacity style={styles.colorAddBtn}>
<Ionicons name="add" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</Card>
{/* Material */}
<Text style={styles.sectionLabel}>MATERIAL</Text>
<View style={styles.materialChips}>
{MATERIALS.map((m) => (
<TouchableOpacity
key={m}
onPress={() => setSelectedMaterial(m)}
style={[styles.chip, selectedMaterial === m && styles.chipActive]}
>
<Text style={[styles.chipText, selectedMaterial === m && styles.chipTextActive]}>{m}</Text>
</TouchableOpacity>
))}
</View>
{/* Marca e Modelo */}
<Controller
control={control}
name="brand"
render={({ field }) => (
<Input label="MARCA" placeholder="Elegoo" error={errors.brand?.message} onChangeText={field.onChange} value={field.value} />
)}
/>
<Controller
control={control}
name="model"
render={({ field }) => (
<Input label="MODELO (OPCIONAL)" placeholder="Ex: Rapid, Matte, Silk..." onChangeText={field.onChange} value={field.value ?? ''} />
)}
/>
{/* Parâmetros de impressão */}
<Text style={styles.sectionLabel}>PARÂMETROS DE IMPRESSÃO</Text>
<View style={styles.paramRow}>
<Controller
control={control}
name="tempHotendC"
render={({ field }) => (
<View style={styles.paramField}>
<Input
label="Hotend (°C)"
keyboardType="numeric"
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="thermometer-outline" size={16} color={colors.stockLow} />}
/>
</View>
)}
/>
<Controller
control={control}
name="tempBedC"
render={({ field }) => (
<View style={styles.paramField}>
<Input
label="Mesa (°C)"
keyboardType="numeric"
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="flag-outline" size={16} color={colors.stockMedium} />}
/>
</View>
)}
/>
<Controller
control={control}
name="flowFactorPct"
render={({ field }) => (
<View style={styles.paramField}>
<Input
label="Fluxo (%)"
keyboardType="numeric"
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="time-outline" size={16} color={colors.accent} />}
/>
</View>
)}
/>
</View>
{/* Preset */}
<View style={styles.presetHeader}>
<Text style={styles.sectionLabel}>PRESET DO CARRETEL</Text>
<TouchableOpacity onPress={() => router.push('/(app)/config/presets/new' as never)}>
<Text style={styles.addPresetLink}>+ Personalizado</Text>
</TouchableOpacity>
</View>
{presets.map((p) => (
<TouchableOpacity
key={p.id}
onPress={() => setSelectedPresetId(p.id)}
style={[styles.presetItem, selectedPresetId === p.id && styles.presetItemActive]}
>
<Ionicons name="disc-outline" size={20} color={selectedPresetId === p.id ? colors.accent : colors.textSecondary} />
<View style={styles.presetInfo}>
<Text style={[styles.presetName, selectedPresetId === p.id && styles.presetNameActive]}>{p.name}</Text>
{selectedPresetId === p.id && <Text style={styles.presetSub}>Carretel: {p.spoolWeightG}g</Text>}
</View>
<Text style={styles.presetWeight}>{p.spoolWeightG}g</Text>
</TouchableOpacity>
))}
{/* Calculadora */}
<Text style={styles.sectionLabel}>CALCULADORA DE PESO</Text>
<Text style={styles.calcLabel}>Peso Total na Balança (g)</Text>
<Controller
control={control}
name="totalWeightG"
render={({ field }) => (
<Card style={styles.calcInput}>
<Ionicons name="scale-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.calcValue}
keyboardType="numeric"
placeholder="0"
placeholderTextColor={colors.textSecondary}
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
/>
<Text style={styles.calcUnit}>g</Text>
</Card>
)}
/>
{netWeight > 0 && (
<Card style={styles.resultCard}>
<View style={styles.resultTop}>
<Text style={styles.resultLabel}>Filamento Disponível</Text>
<Text style={styles.resultCalc}>{totalWeight}g {selectedPreset?.spoolWeightG}g</Text>
</View>
<View style={styles.resultBottom}>
<Text style={styles.resultValue}>{formatWeight(netWeight)}</Text>
<View style={styles.resultPctBadge}>
<View style={[styles.resultDot, { backgroundColor: colors.accent }]} />
<Text style={styles.resultPct}>{pct}%</Text>
</View>
</View>
</Card>
)}
</View>
</ScrollView>
{/* CTA fixo */}
<View style={styles.footer}>
<Button
label="Salvar Alterações"
leftIcon={<Ionicons name="save-outline" size={18} color={colors.bgBase} />}
onPress={handleSubmit(onSubmit)}
isLoading={isLoading}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
notFound: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing[4] },
notFoundText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
backLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.accent },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing[5], paddingVertical: spacing[4] },
headerTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.md, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
content: { paddingHorizontal: spacing[5], gap: spacing[4], paddingBottom: spacing[10] },
sectionLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.bold, color: colors.textSecondary, letterSpacing: 0.8, textTransform: 'uppercase' },
colorCard: { gap: spacing[3] },
colorPreviewRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
colorSquare: { width: 40, height: 40, borderRadius: radius.sm },
hexValue: { flex: 1, fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
colorPalette: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
colorDot: { width: 32, height: 32, borderRadius: radius.full, borderWidth: 2, borderColor: 'transparent' },
colorDotActive: { borderColor: colors.accent },
colorAddBtn: { width: 32, height: 32, borderRadius: radius.full, backgroundColor: colors.bgHover, alignItems: 'center', justifyContent: 'center' },
materialChips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
chip: { paddingHorizontal: spacing[4], paddingVertical: spacing[2], borderRadius: radius.full, backgroundColor: colors.bgSurface, borderWidth: 1, borderColor: colors.border },
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.medium, color: colors.textSecondary },
chipTextActive: { color: colors.bgBase },
paramRow: { flexDirection: 'row', gap: spacing[3] },
paramField: { flex: 1 },
presetHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
addPresetLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.accent },
presetItem: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[4], borderWidth: 1, borderColor: colors.border },
presetItemActive: { borderColor: colors.accent },
presetInfo: { flex: 1 },
presetName: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textPrimary },
presetNameActive: { color: colors.accent },
presetSub: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
presetWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
calcLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
calcInput: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
calcValue: { flex: 1, fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
calcUnit: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
resultCard: { backgroundColor: colors.bgHover },
resultTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing[2] },
resultLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
resultCalc: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
resultBottom: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
resultValue: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.accent },
resultPctBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: colors.accentMuted, paddingHorizontal: spacing[3], paddingVertical: 4, borderRadius: radius.sm },
resultDot: { width: 8, height: 8, borderRadius: radius.full },
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
});
+278
View File
@@ -0,0 +1,278 @@
import React, { useState } from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, ScrollView,
} 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 { colors, typography, spacing, radius } from '@shared/theme';
import { MATERIALS, type Material } from '@shared/constants';
import { Button } from '@presentation/components/ui/Button';
import type { FilamentFilter } from '@domain/Filament';
const BRANDS = ['Elegoo', 'Bambu Lab', 'Prusament', 'Polymaker', 'Creality'];
type StockLevel = 'all' | 'low' | 'medium';
/**
* Tela de Filtros — 17H-0
* Renderizada como modal / bottom sheet a partir da tela de Inventário.
*/
export default function FiltersScreen(): React.ReactElement {
const router = useRouter();
const { activeFilter, setFilter, resetFilter } = useFilamentStore();
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
activeFilter.material ? [activeFilter.material] : [],
);
const [selectedBrands, setSelectedBrands] = useState<string[]>(
activeFilter.brand ? [activeFilter.brand] : [],
);
const [stockLevel, setStockLevel] = useState<StockLevel>(
activeFilter.stockLevel === 'low' ? 'low' : activeFilter.stockLevel === 'medium' ? 'medium' : 'all',
);
function toggleMaterial(m: Material): void {
setSelectedMaterials((prev) =>
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
);
}
function toggleBrand(b: string): void {
setSelectedBrands((prev) =>
prev.includes(b) ? prev.filter((x) => x !== b) : [...prev, b],
);
}
function handleApply(): void {
const filter: FilamentFilter = {
...(selectedMaterials.length === 1 ? { material: selectedMaterials[0] } : {}),
...(selectedBrands.length === 1 ? { brand: selectedBrands[0] } : {}),
...(stockLevel !== 'all' ? { stockLevel: stockLevel as FilamentFilter['stockLevel'] } : {}),
sortBy: 'created_at_desc',
page: 1,
perPage: 50,
};
setFilter(filter);
router.back();
}
function handleClear(): void {
setSelectedMaterials([]);
setSelectedBrands([]);
setStockLevel('all');
resetFilter();
}
return (
<SafeAreaView style={styles.safe}>
{/* Dimmed background area (topo) */}
<TouchableOpacity style={styles.backdrop} onPress={() => router.back()} />
{/* Bottom sheet */}
<View style={styles.sheet}>
{/* Handle */}
<View style={styles.handle} />
{/* Header */}
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Filtros</Text>
<TouchableOpacity onPress={handleClear}>
<Text style={styles.clearText}>Limpar tudo</Text>
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false} style={styles.scroll}>
{/* Material */}
<Text style={styles.sectionLabel}>MATERIAL</Text>
<View style={styles.chips}>
{MATERIALS.map((m) => (
<TouchableOpacity
key={m}
onPress={() => toggleMaterial(m)}
style={[styles.chip, selectedMaterials.includes(m) && styles.chipActive]}
>
<Text style={[styles.chipText, selectedMaterials.includes(m) && styles.chipTextActive]}>
{m}
</Text>
</TouchableOpacity>
))}
</View>
{/* Marca */}
<Text style={[styles.sectionLabel, { marginTop: spacing[5] }]}>MARCA</Text>
<View style={styles.chips}>
{BRANDS.map((b) => (
<TouchableOpacity
key={b}
onPress={() => toggleBrand(b)}
style={[styles.chip, selectedBrands.includes(b) && styles.chipActive]}
>
<Text style={[styles.chipText, selectedBrands.includes(b) && styles.chipTextActive]}>
{b}
</Text>
</TouchableOpacity>
))}
</View>
{/* Nível de Estoque */}
<Text style={[styles.sectionLabel, { marginTop: spacing[5] }]}>NÍVEL DE ESTOQUE</Text>
<View style={styles.radioGroup}>
<TouchableOpacity
style={[styles.radioItem, stockLevel === 'all' && styles.radioItemActive]}
onPress={() => setStockLevel('all')}
>
<Text style={styles.radioLabel}>Todos</Text>
<View style={[styles.radioCircle, stockLevel === 'all' && styles.radioCircleActive]}>
{stockLevel === 'all' && <Ionicons name="checkmark" size={14} color={colors.bgBase} />}
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.radioItem, stockLevel === 'medium' && styles.radioItemActive]}
onPress={() => setStockLevel('medium')}
>
<View style={styles.radioLabelRow}>
<Text style={styles.radioLabel}>Estoque baixo</Text>
<View style={styles.radioBadge}>
<Text style={styles.radioBadgeText}>abaixo de 25%</Text>
</View>
</View>
<View style={[styles.radioCircle, stockLevel === 'medium' && styles.radioCircleActive]}>
{stockLevel === 'medium' && <Ionicons name="checkmark" size={14} color={colors.bgBase} />}
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.radioItem, stockLevel === 'low' && styles.radioItemActive]}
onPress={() => setStockLevel('low')}
>
<View style={styles.radioLabelRow}>
<Text style={styles.radioLabel}>Quase vazio</Text>
<View style={[styles.radioBadge, styles.radioBadgeCritical]}>
<Text style={[styles.radioBadgeText, styles.radioBadgeTextCritical]}>abaixo de 10%</Text>
</View>
</View>
<View style={[styles.radioCircle, stockLevel === 'low' && styles.radioCircleActive]}>
{stockLevel === 'low' && <Ionicons name="checkmark" size={14} color={colors.bgBase} />}
</View>
</TouchableOpacity>
</View>
</ScrollView>
{/* CTA */}
<View style={styles.footer}>
<Button label="Aplicar Filtros" onPress={handleApply} />
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: 'transparent' },
backdrop: { flex: 1, backgroundColor: colors.overlay },
sheet: {
backgroundColor: colors.bgSurface,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
paddingBottom: spacing[5],
},
handle: {
width: 40,
height: 4,
backgroundColor: colors.border,
borderRadius: radius.full,
alignSelf: 'center',
marginTop: spacing[3],
marginBottom: spacing[2],
},
sheetHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
},
sheetTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
clearText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
},
scroll: { paddingHorizontal: spacing[5], maxHeight: 440 },
sectionLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
color: colors.textSecondary,
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: spacing[3],
},
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
chip: {
paddingHorizontal: spacing[4],
paddingVertical: spacing[2],
borderRadius: radius.full,
backgroundColor: colors.bgHover,
borderWidth: 1,
borderColor: colors.border,
},
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.textSecondary,
},
chipTextActive: { color: colors.bgBase },
radioGroup: { gap: spacing[2], paddingBottom: spacing[4] },
radioItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.bgHover,
borderRadius: radius.lg,
padding: spacing[4],
borderWidth: 1,
borderColor: colors.border,
},
radioItemActive: { borderColor: colors.accent },
radioLabelRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
radioLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textPrimary,
},
radioCircle: {
width: 24,
height: 24,
borderRadius: radius.full,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
radioCircleActive: { backgroundColor: colors.accent, borderColor: colors.accent },
radioBadge: {
backgroundColor: colors.accentMuted,
paddingHorizontal: spacing[2],
paddingVertical: 2,
borderRadius: radius.sm,
},
radioBadgeCritical: { backgroundColor: 'rgba(255,107,107,0.15)' },
radioBadgeText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.accent,
},
radioBadgeTextCritical: { color: colors.stockLow },
footer: { paddingHorizontal: spacing[5], paddingTop: spacing[3] },
});
+312
View File
@@ -0,0 +1,312 @@
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
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 { 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';
const schema = z.object({
brand: z.string().min(1, 'Marca obrigatória'),
model: z.string().optional(),
totalWeightG: z.coerce.number().min(1, 'Informe o peso total'),
tempHotendC: z.coerce.number().optional(),
tempBedC: z.coerce.number().optional(),
flowFactorPct: z.coerce.number().optional(),
notes: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
const PRESET_COLORS = ['#E05533', '#2563EB', '#FFFFFF', '#1E1B18', '#22C55E', '#EAB308', '#EC4899', '#A855F7', '#F97316'];
/**
* Tela de Cadastro de Filamento — 2X-0
*/
export default function NewFilamentScreen(): React.ReactElement {
const router = useRouter();
const { presets, systemPresets } = usePresetStore();
const { addFilament } = useFilamentStore();
const [selectedColor, setSelectedColor] = useState('#E05533');
const [hexInput, setHexInput] = useState('#E05533');
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { brand: '', model: '', totalWeightG: 0, tempHotendC: 210, tempBedC: 60, flowFactorPct: 100 },
});
const totalWeight = watch('totalWeightG');
const selectedPreset = presets.find((p) => p.id === selectedPresetId);
const netWeight = selectedPreset && totalWeight
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
: 0;
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
async function onSubmit(data: FormData): Promise<void> {
if (!selectedPresetId) {
Alert.alert('Atenção', 'Selecione um preset de carretel.');
return;
}
setIsLoading(true);
try {
// TODO: CreateFilamentUseCase via container DI
console.log('create filament', { ...data, material: selectedMaterial, colorHex: selectedColor, spoolPresetId: selectedPresetId });
router.back();
} catch {
Alert.alert('Erro', 'Não foi possível salvar o filamento.');
} finally {
setIsLoading(false);
}
}
return (
<SafeAreaView style={styles.safe}>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Novo Filamento</Text>
<View style={{ width: 24 }} />
</View>
<View style={styles.content}>
{/* Cor */}
<Text style={styles.sectionLabel}>COR DO FILAMENTO</Text>
<Card style={styles.colorCard}>
<View style={styles.colorPreviewRow}>
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
<Text style={styles.hexValue}>{hexInput}</Text>
<TouchableOpacity><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
</View>
<View style={styles.colorPalette}>
{PRESET_COLORS.map((c) => (
<TouchableOpacity
key={c}
onPress={() => { setSelectedColor(c); setHexInput(c); }}
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
/>
))}
<TouchableOpacity style={styles.colorAddBtn}>
<Ionicons name="add" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</Card>
{/* Material */}
<Text style={styles.sectionLabel}>MATERIAL</Text>
<View style={styles.materialChips}>
{MATERIALS.map((m) => (
<TouchableOpacity
key={m}
onPress={() => setSelectedMaterial(m)}
style={[styles.chip, selectedMaterial === m && styles.chipActive]}
>
<Text style={[styles.chipText, selectedMaterial === m && styles.chipTextActive]}>{m}</Text>
</TouchableOpacity>
))}
</View>
{/* Marca e Modelo */}
<Controller
control={control}
name="brand"
render={({ field }) => (
<Input label="MARCA" placeholder="Elegoo" error={errors.brand?.message} onChangeText={field.onChange} value={field.value} />
)}
/>
<Controller
control={control}
name="model"
render={({ field }) => (
<Input label="MODELO (OPCIONAL)" placeholder="Ex: Rapid, Matte, Silk..." onChangeText={field.onChange} value={field.value ?? ''} />
)}
/>
{/* Parâmetros de impressão */}
<Text style={styles.sectionLabel}>PARÂMETROS DE IMPRESSÃO</Text>
<View style={styles.paramRow}>
<Controller
control={control}
name="tempHotendC"
render={({ field }) => (
<View style={styles.paramField}>
<Input
label="Hotend (°C)"
keyboardType="numeric"
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="thermometer-outline" size={16} color={colors.stockLow} />}
/>
</View>
)}
/>
<Controller
control={control}
name="tempBedC"
render={({ field }) => (
<View style={styles.paramField}>
<Input
label="Mesa (°C)"
keyboardType="numeric"
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="flag-outline" size={16} color={colors.stockMedium} />}
/>
</View>
)}
/>
<Controller
control={control}
name="flowFactorPct"
render={({ field }) => (
<View style={styles.paramField}>
<Input
label="Fluxo (%)"
keyboardType="numeric"
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
leftIcon={<Ionicons name="time-outline" size={16} color={colors.accent} />}
/>
</View>
)}
/>
</View>
{/* Preset */}
<View style={styles.presetHeader}>
<Text style={styles.sectionLabel}>PRESET DO CARRETEL</Text>
<TouchableOpacity onPress={() => router.push('/(app)/config/presets/new')}>
<Text style={styles.addPresetLink}>+ Personalizado</Text>
</TouchableOpacity>
</View>
{presets.map((p) => (
<TouchableOpacity
key={p.id}
onPress={() => setSelectedPresetId(p.id)}
style={[styles.presetItem, selectedPresetId === p.id && styles.presetItemActive]}
>
<Ionicons name="disc-outline" size={20} color={selectedPresetId === p.id ? colors.accent : colors.textSecondary} />
<View style={styles.presetInfo}>
<Text style={[styles.presetName, selectedPresetId === p.id && styles.presetNameActive]}>{p.name}</Text>
{selectedPresetId === p.id && <Text style={styles.presetSub}>Carretel: {p.spoolWeightG}g</Text>}
</View>
<Text style={styles.presetWeight}>{p.spoolWeightG}g</Text>
</TouchableOpacity>
))}
{/* Calculadora */}
<Text style={styles.sectionLabel}>CALCULADORA DE PESO</Text>
<Text style={styles.calcLabel}>Peso Total na Balança (g)</Text>
<Controller
control={control}
name="totalWeightG"
render={({ field }) => (
<Card style={styles.calcInput}>
<Ionicons name="scale-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.calcValue}
keyboardType="numeric"
placeholder="0"
placeholderTextColor={colors.textSecondary}
onChangeText={field.onChange}
value={field.value?.toString() ?? ''}
/>
<Text style={styles.calcUnit}>g</Text>
</Card>
)}
/>
{netWeight > 0 && (
<Card style={styles.resultCard}>
<View style={styles.resultTop}>
<Text style={styles.resultLabel}>Filamento Disponível</Text>
<Text style={styles.resultCalc}>{totalWeight}g {selectedPreset?.spoolWeightG}g</Text>
</View>
<View style={styles.resultBottom}>
<Text style={styles.resultValue}>{formatWeight(netWeight)}</Text>
<View style={styles.resultPctBadge}>
<View style={[styles.resultDot, { backgroundColor: colors.accent }]} />
<Text style={styles.resultPct}>{pct}%</Text>
</View>
</View>
</Card>
)}
</View>
</ScrollView>
{/* CTA fixo */}
<View style={styles.footer}>
<Button
label="Salvar Filamento"
leftIcon={<Ionicons name="save-outline" size={18} color={colors.bgBase} />}
onPress={handleSubmit(onSubmit)}
isLoading={isLoading}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bgBase },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing[5], paddingVertical: spacing[4] },
headerTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.md, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
content: { paddingHorizontal: spacing[5], gap: spacing[4], paddingBottom: spacing[10] },
sectionLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.bold, color: colors.textSecondary, letterSpacing: 0.8, textTransform: 'uppercase' },
colorCard: { gap: spacing[3] },
colorPreviewRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
colorSquare: { width: 40, height: 40, borderRadius: radius.sm },
hexValue: { flex: 1, fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
colorPalette: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
colorDot: { width: 32, height: 32, borderRadius: radius.full, borderWidth: 2, borderColor: 'transparent' },
colorDotActive: { borderColor: colors.accent },
colorAddBtn: { width: 32, height: 32, borderRadius: radius.full, backgroundColor: colors.bgHover, alignItems: 'center', justifyContent: 'center' },
materialChips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[2] },
chip: { paddingHorizontal: spacing[4], paddingVertical: spacing[2], borderRadius: radius.full, backgroundColor: colors.bgSurface, borderWidth: 1, borderColor: colors.border },
chipActive: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.medium, color: colors.textSecondary },
chipTextActive: { color: colors.bgBase },
paramRow: { flexDirection: 'row', gap: spacing[3] },
paramField: { flex: 1 },
presetHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
addPresetLink: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.accent },
presetItem: { flexDirection: 'row', alignItems: 'center', gap: spacing[3], backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[4], borderWidth: 1, borderColor: colors.border },
presetItemActive: { borderColor: colors.accent },
presetInfo: { flex: 1 },
presetName: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textPrimary },
presetNameActive: { color: colors.accent },
presetSub: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
presetWeight: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
calcLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
calcInput: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
calcValue: { flex: 1, fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.textPrimary },
calcUnit: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, color: colors.textSecondary },
resultCard: { backgroundColor: colors.bgHover },
resultTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing[2] },
resultLabel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, color: colors.textSecondary },
resultCalc: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.xs, color: colors.textSecondary },
resultBottom: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
resultValue: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize['2xl'], fontWeight: typography.fontWeight.bold, color: colors.accent },
resultPctBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: colors.accentMuted, paddingHorizontal: spacing[3], paddingVertical: 4, borderRadius: radius.sm },
resultDot: { width: 8, height: 8, borderRadius: radius.full },
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
});
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Stack, Redirect } from 'expo-router';
import { useAuthStore } from '@store/authStore';
/**
* Layout do grupo de rotas de autenticação: (auth).
* Se o usuário já estiver autenticado, redireciona para o app.
*/
export default function AuthLayout(): React.ReactElement {
const { isAuthenticated } = useAuthStore();
if (isAuthenticated) {
return <Redirect href="/(app)/(tabs)/home" />;
}
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }} />
);
}
+123
View File
@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Alert } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
const schema = z.object({
email: z.string().email('E-mail inválido'),
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Recuperação de Senha — L2-0
*/
export default function ForgotPasswordScreen(): React.ReactElement {
const [isLoading, setIsLoading] = useState(false);
const [sent, setSent] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '' },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: ForgotPasswordUseCase
console.log('forgot-password', data);
setSent(true);
} catch {
Alert.alert('Erro', 'Não foi possível enviar o e-mail.');
} finally {
setIsLoading(false);
}
}
return (
<Screen keyboardAvoiding>
<Header title="Recuperar senha" showBack />
<View style={styles.content}>
{sent ? (
<View style={styles.successCard}>
<Ionicons name="mail-open-outline" size={48} color={colors.accent} />
<Text style={styles.successTitle}>E-mail enviado!</Text>
<Text style={styles.successText}>
Verifique sua caixa de entrada e siga as instruções para redefinir sua senha.
</Text>
</View>
) : (
<>
<Text style={styles.description}>
Informe seu e-mail cadastrado. Enviaremos um link para você redefinir sua senha.
</Text>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
label="E-MAIL"
placeholder="seu@email.com"
keyboardType="email-address"
autoComplete="email"
leftIcon={<Ionicons name="mail-outline" size={18} color={colors.textSecondary} />}
error={errors.email?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Button label="Enviar link" onPress={handleSubmit(onSubmit)} isLoading={isLoading} style={styles.cta} />
</>
)}
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
paddingTop: spacing[6],
gap: spacing[5],
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
lineHeight: typography.fontSize.base * 1.6,
},
cta: {
marginTop: spacing[2],
},
successCard: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[4],
paddingHorizontal: spacing[4],
},
successTitle: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
successText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
});
+213
View File
@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Link, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
const schema = z.object({
email: z.string().email('E-mail inválido'),
password: z.string().min(1, 'Senha obrigatória'),
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Login — G3-0
* E-mail + Senha, "Esqueci minha senha" e OAuth Google.
*/
export default function LoginScreen(): React.ReactElement {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: injetar LoginUseCase via container de DI
console.log('login', data);
router.replace('/(app)/(tabs)/home');
} catch (err) {
Alert.alert('Erro', 'E-mail ou senha incorretos.');
} finally {
setIsLoading(false);
}
}
async function onGoogleLogin(): Promise<void> {
setIsGoogleLoading(true);
try {
// TODO: Google Sign-In + GoogleLoginUseCase
router.replace('/(app)/(tabs)/home');
} catch {
Alert.alert('Erro', 'Não foi possível entrar com o Google.');
} finally {
setIsGoogleLoading(false);
}
}
return (
<Screen scrollable keyboardAvoiding>
{/* Logo */}
<View style={styles.hero}>
<View style={styles.logoContainer}>
<Ionicons name="paw" size={48} color={colors.accent} />
</View>
<Text style={styles.appName}>MeowSpool</Text>
<Text style={styles.tagline}>Gerencie seus filamentos com precisão.</Text>
</View>
{/* Formulário */}
<View style={styles.form}>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
label="E-MAIL"
placeholder="seu@email.com"
keyboardType="email-address"
autoComplete="email"
leftIcon={<Ionicons name="mail-outline" size={18} color={colors.textSecondary} />}
error={errors.email?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
label="SENHA"
placeholder="••••••••"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.password?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<TouchableOpacity onPress={() => router.push('/(auth)/forgot-password')} style={styles.forgotRow}>
<Text style={styles.forgotText}>Esqueci minha senha</Text>
</TouchableOpacity>
<Button label="Entrar" onPress={handleSubmit(onSubmit)} isLoading={isLoading} />
{/* Divisor */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>ou continue com</Text>
<View style={styles.dividerLine} />
</View>
{/* Google */}
<Button
label="Entrar com Google"
variant="secondary"
leftIcon={<Ionicons name="logo-google" size={18} color={colors.textPrimary} />}
onPress={onGoogleLogin}
isLoading={isGoogleLoading}
/>
{/* Criar conta */}
<View style={styles.footer}>
<Text style={styles.footerText}>Não tem uma conta? </Text>
<Link href="/(auth)/register" asChild>
<TouchableOpacity>
<Text style={styles.footerLink}>Criar conta</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
hero: {
alignItems: 'center',
paddingTop: spacing[12],
paddingBottom: spacing[10],
gap: spacing[2],
},
logoContainer: {
width: 80,
height: 80,
borderRadius: 20,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
appName: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize['2xl'],
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
tagline: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
},
form: {
gap: spacing[4],
paddingBottom: spacing[8],
},
forgotRow: {
alignSelf: 'flex-end',
marginTop: -spacing[2],
},
forgotText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
marginVertical: spacing[2],
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
dividerText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing[2],
},
footerText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footerLink: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
fontWeight: typography.fontWeight.semibold,
},
});
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela de Senha Redefinida — 135-0
* Confirmação de sucesso após redefinição de senha.
*/
export default function PasswordResetDoneScreen(): React.ReactElement {
const router = useRouter();
return (
<Screen>
<View style={styles.content}>
<View style={styles.iconWrapper}>
<Ionicons name="checkmark-circle" size={64} color={colors.accent} />
</View>
<Text style={styles.title}>Senha redefinida!</Text>
<Text style={styles.description}>
Sua senha foi alterada com sucesso. Você pode fazer login com a nova senha.
</Text>
<Button
label="Ir para o Login"
onPress={() => router.replace('/(auth)/login')}
style={styles.cta}
/>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[5],
paddingHorizontal: spacing[4],
},
iconWrapper: {
width: 100,
height: 100,
borderRadius: radius.xl,
backgroundColor: 'rgba(56,188,194,0.12)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
title: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
textAlign: 'center',
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
cta: { width: '100%', marginTop: spacing[4] },
});
+146
View File
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Link, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
const schema = z.object({
email: z.string().email('E-mail inválido'),
password: z
.string()
.min(8, 'Mínimo 8 caracteres'),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Cadastro — IX-0
*/
export default function RegisterScreen(): React.ReactElement {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', confirmPassword: '' },
});
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
// TODO: injetar RegisterUseCase
console.log('register', data);
router.replace('/(auth)/verify-email');
} catch {
Alert.alert('Erro', 'Não foi possível criar sua conta.');
} finally {
setIsLoading(false);
}
}
return (
<Screen scrollable keyboardAvoiding>
<Header title="Criar conta" showBack />
<View style={styles.form}>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input
label="E-MAIL"
placeholder="seu@email.com"
keyboardType="email-address"
autoComplete="email"
leftIcon={<Ionicons name="mail-outline" size={18} color={colors.textSecondary} />}
error={errors.email?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
label="SENHA"
placeholder="Mínimo 8 caracteres"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.password?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field }) => (
<Input
label="CONFIRMAR SENHA"
placeholder="Repita a senha"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.confirmPassword?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Button label="Criar conta" onPress={handleSubmit(onSubmit)} isLoading={isLoading} style={styles.cta} />
<View style={styles.footer}>
<Text style={styles.footerText}> tem uma conta? </Text>
<Link href="/(auth)/login" asChild>
<TouchableOpacity>
<Text style={styles.footerLink}>Entrar</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
form: {
gap: spacing[4],
paddingTop: spacing[6],
paddingBottom: spacing[8],
},
cta: {
marginTop: spacing[2],
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing[4],
},
footerText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
footerLink: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
fontWeight: typography.fontWeight.semibold,
},
});
+116
View File
@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Alert } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
const schema = z.object({
password: z.string().min(8, 'Mínimo 8 caracteres'),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type FormData = z.infer<typeof schema>;
/**
* Tela de Redefinir Senha — 117-0
* Recebe `token` via deep link: meowspool://reset-password?token=xxx
*/
export default function ResetPasswordScreen(): React.ReactElement {
const { token } = useLocalSearchParams<{ token: string }>();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { password: '', confirmPassword: '' },
});
async function onSubmit(data: FormData): Promise<void> {
if (!token) {
Alert.alert('Erro', 'Token inválido.');
return;
}
setIsLoading(true);
try {
// TODO: ResetPasswordUseCase
console.log('reset-password', { token, ...data });
router.replace('/(auth)/password-reset-done');
} catch {
Alert.alert('Erro', 'Não foi possível redefinir a senha.');
} finally {
setIsLoading(false);
}
}
return (
<Screen keyboardAvoiding>
<Header title="Nova senha" showBack />
<View style={styles.content}>
<Text style={styles.description}>
Escolha uma nova senha segura para sua conta.
</Text>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
label="NOVA SENHA"
placeholder="Mínimo 8 caracteres"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.password?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field }) => (
<Input
label="CONFIRMAR NOVA SENHA"
placeholder="Repita a senha"
isPassword
leftIcon={<Ionicons name="lock-closed-outline" size={18} color={colors.textSecondary} />}
error={errors.confirmPassword?.message}
onChangeText={field.onChange}
value={field.value}
/>
)}
/>
<Button label="Redefinir senha" onPress={handleSubmit(onSubmit)} isLoading={isLoading} style={styles.cta} />
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
paddingTop: spacing[6],
gap: spacing[4],
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
lineHeight: typography.fontSize.base * 1.6,
marginBottom: spacing[2],
},
cta: { marginTop: spacing[2] },
});
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { colors, typography, spacing, radius } from '@shared/theme';
/**
* Tela de Verificação de E-mail — RU-0
* Mostrada após o cadastro bem-sucedido.
*/
export default function VerifyEmailScreen(): React.ReactElement {
return (
<Screen>
<Header title="Verificar e-mail" showBack />
<View style={styles.content}>
<View style={styles.iconWrapper}>
<Ionicons name="mail-outline" size={64} color={colors.accent} />
</View>
<Text style={styles.title}>Confirme seu e-mail</Text>
<Text style={styles.description}>
Enviamos um link de verificação para o seu e-mail. Clique no link para ativar sua conta e poder fazer login.
</Text>
<View style={styles.hint}>
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
<Text style={styles.hintText}>Não recebeu? Verifique sua pasta de spam.</Text>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[5],
paddingHorizontal: spacing[4],
},
iconWrapper: {
width: 100,
height: 100,
borderRadius: radius.xl,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
title: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
textAlign: 'center',
},
description: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
hint: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[2],
marginTop: spacing[4],
},
hintText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
});
+28
View File
@@ -0,0 +1,28 @@
import React, { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useAuthStore } from '@store/authStore';
/**
* Root layout do expo-router.
* Carrega a sessão armazenada e controla o fluxo auth vs app.
*/
export default function RootLayout(): React.ReactElement | null {
const { loadStoredSession, isLoading } = useAuthStore();
useEffect(() => {
loadStoredSession();
}, [loadStoredSession]);
if (isLoading) return null;
return (
<>
<StatusBar style="light" backgroundColor="transparent" translucent />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#1E1B18' } }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
</Stack>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+24
View File
@@ -0,0 +1,24 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
root: ['./src'],
alias: {
'@domain': './src/domain',
'@ports': './src/ports',
'@application': './src/application',
'@adapters': './src/adapters',
'@presentation': './src/presentation',
'@store': './src/store',
'@shared': './src/shared',
},
},
],
'react-native-reanimated/plugin',
],
};
};
+1
View File
@@ -0,0 +1 @@
import 'expo-router/entry';
+7
View File
@@ -0,0 +1,7 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
module.exports = config;
+12961
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
{
"name": "meowspool-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint src --ext .ts,.tsx",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@hookform/resolvers": "^3.9.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
"@react-navigation/native-stack": "^7.14.5",
"axios": "^1.7.7",
"expo": "~54.0.0",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
"react": "19.1.0",
"react-hook-form": "^7.53.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-qrcode-svg": "^6.3.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~19.1.10",
"@types/react-native": "~0.73.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-native": "^4.1.0",
"typescript": "~5.9.2"
}
}
@@ -0,0 +1,127 @@
import { getDatabase } from './database';
import type { FilamentRepository } from '@ports/FilamentRepository';
import type { Filament, CreateFilamentInput, UpdateFilamentInput, FilamentFilter } from '@domain/Filament';
function rowToFilament(row: Record<string, unknown>): Filament {
return {
id: row.id as string,
userId: row.user_id as string,
material: row.material as Filament['material'],
brand: row.brand as string,
model: (row.model as string | null) ?? null,
colorHex: row.color_hex as string,
spoolPresetId: row.spool_preset_id as string,
totalWeightG: row.total_weight_g as number,
netWeightG: row.net_weight_g as number,
tempHotendC: (row.temp_hotend_c as number | null) ?? null,
tempBedC: (row.temp_bed_c as number | null) ?? null,
flowFactorPct: (row.flow_factor_pct as number | null) ?? null,
notes: (row.notes as string | null) ?? null,
updatedAt: row.updated_at as string,
createdAt: row.created_at as string,
};
}
/**
* Implementação local (SQLite) do FilamentRepository.
* Persiste todos os dados offline-first. A sync layer envia para a API quando online.
*/
export class LocalFilamentRepository implements FilamentRepository {
async findById(id: string, userId: string): Promise<Filament | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<Record<string, unknown>>(
'SELECT * FROM filaments WHERE id = ? AND user_id = ?',
[id, userId],
);
return row ? rowToFilament(row) : null;
}
async list(userId: string, filter?: FilamentFilter): Promise<Filament[]> {
const db = await getDatabase();
let query = 'SELECT * FROM filaments WHERE user_id = ?';
const params: (string | number | null)[] = [userId];
if (filter?.material) {
query += ' AND material = ?';
params.push(filter.material);
}
if (filter?.brand) {
query += ' AND brand LIKE ?';
params.push(`%${filter.brand}%`);
}
if (filter?.search) {
query += ' AND (brand LIKE ? OR model LIKE ? OR notes LIKE ?)';
params.push(`%${filter.search}%`, `%${filter.search}%`, `%${filter.search}%`);
}
const orderMap: Record<string, string> = {
net_weight_asc: 'net_weight_g ASC',
net_weight_desc: 'net_weight_g DESC',
created_at_desc: 'created_at DESC',
};
query += ` ORDER BY ${orderMap[filter?.sortBy ?? 'created_at_desc']}`;
const rows = await db.getAllAsync<Record<string, unknown>>(query, params);
return rows.map(rowToFilament);
}
async create(
userId: string,
input: CreateFilamentInput & { netWeightG: number },
): Promise<Filament> {
const db = await getDatabase();
const id = `local_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const now = new Date().toISOString();
await db.runAsync(
`INSERT INTO filaments
(id, user_id, material, brand, model, color_hex, spool_preset_id,
total_weight_g, net_weight_g, temp_hotend_c, temp_bed_c,
flow_factor_pct, notes, updated_at, created_at, synced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
[
id, userId, input.material, input.brand, input.model ?? null,
input.colorHex, input.spoolPresetId, input.totalWeightG, input.netWeightG,
input.tempHotendC ?? null, input.tempBedC ?? null,
input.flowFactorPct ?? null, input.notes ?? null, now, now,
],
);
const created = await this.findById(id, userId);
return created!;
}
async update(id: string, userId: string, input: UpdateFilamentInput & { netWeightG?: number }): Promise<Filament> {
const db = await getDatabase();
const now = new Date().toISOString();
const fields: string[] = ['updated_at = ?', 'synced = 0'];
const params: (string | number | null)[] = [now];
if (input.material) { fields.push('material = ?'); params.push(input.material); }
if (input.brand) { fields.push('brand = ?'); params.push(input.brand); }
if (input.model !== undefined) { fields.push('model = ?'); params.push(input.model); }
if (input.colorHex) { fields.push('color_hex = ?'); params.push(input.colorHex); }
if (input.spoolPresetId) { fields.push('spool_preset_id = ?'); params.push(input.spoolPresetId); }
if (input.totalWeightG !== undefined) { fields.push('total_weight_g = ?'); params.push(input.totalWeightG); }
if (input.netWeightG !== undefined) { fields.push('net_weight_g = ?'); params.push(input.netWeightG); }
if (input.tempHotendC !== undefined) { fields.push('temp_hotend_c = ?'); params.push(input.tempHotendC); }
if (input.tempBedC !== undefined) { fields.push('temp_bed_c = ?'); params.push(input.tempBedC); }
if (input.flowFactorPct !== undefined) { fields.push('flow_factor_pct = ?'); params.push(input.flowFactorPct); }
if (input.notes !== undefined) { fields.push('notes = ?'); params.push(input.notes); }
params.push(id, userId);
await db.runAsync(
`UPDATE filaments SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`,
params,
);
const updated = await this.findById(id, userId);
return updated!;
}
async remove(id: string, userId: string): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM filaments WHERE id = ? AND user_id = ?', [id, userId]);
}
}
@@ -0,0 +1,87 @@
import { getDatabase } from './database';
import type { SpoolPresetRepository } from '@ports/SpoolPresetRepository';
import type { SpoolPreset, CreateSpoolPresetInput, UpdateSpoolPresetInput } from '@domain/SpoolPreset';
function rowToPreset(row: Record<string, unknown>): SpoolPreset {
return {
id: row.id as string,
name: row.name as string,
spoolWeightG: row.spool_weight_g as number,
isSystem: Boolean(row.is_system),
userId: (row.user_id as string | null) ?? null,
createdAt: row.created_at as string,
};
}
/**
* Implementação local (SQLite) do SpoolPresetRepository.
*/
export class LocalSpoolPresetRepository implements SpoolPresetRepository {
async list(userId: string): Promise<SpoolPreset[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<Record<string, unknown>>(
'SELECT * FROM spool_presets WHERE is_system = 1 OR user_id = ? ORDER BY is_system DESC, name ASC',
[userId],
);
return rows.map(rowToPreset);
}
async findById(id: string): Promise<SpoolPreset | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<Record<string, unknown>>(
'SELECT * FROM spool_presets WHERE id = ?',
[id],
);
return row ? rowToPreset(row) : null;
}
async create(userId: string, input: CreateSpoolPresetInput): Promise<SpoolPreset> {
const db = await getDatabase();
const id = `local_preset_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const now = new Date().toISOString();
await db.runAsync(
'INSERT INTO spool_presets (id, name, spool_weight_g, is_system, user_id, created_at) VALUES (?, ?, ?, 0, ?, ?)',
[id, input.name, input.spoolWeightG, userId, now],
);
const created = await this.findById(id);
return created!;
}
async update(id: string, _userId: string, input: UpdateSpoolPresetInput): Promise<SpoolPreset> {
const db = await getDatabase();
const fields: string[] = [];
const params: (string | number | null)[] = [];
if (input.name) { fields.push('name = ?'); params.push(input.name); }
if (input.spoolWeightG !== undefined) { fields.push('spool_weight_g = ?'); params.push(input.spoolWeightG); }
if (fields.length > 0) {
params.push(id);
await db.runAsync(`UPDATE spool_presets SET ${fields.join(', ')} WHERE id = ?`, params);
}
const updated = await this.findById(id);
return updated!;
}
async remove(id: string): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM spool_presets WHERE id = ? AND is_system = 0', [id]);
}
/**
* Upsert utilizado pelo SyncService para popular presets do sistema vindos da API.
*/
async upsertSystemPresets(presets: SpoolPreset[]): Promise<void> {
const db = await getDatabase();
for (const p of presets) {
await db.runAsync(
`INSERT OR REPLACE INTO spool_presets (id, name, spool_weight_g, is_system, user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[p.id, p.name, p.spoolWeightG, p.isSystem ? 1 : 0, p.userId ?? null, p.createdAt],
);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import { openDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite';
import { LOCAL_DB_NAME } from '@shared/constants';
let _db: SQLiteDatabase | null = null;
/**
* Retorna a instância singleton do banco SQLite local.
* Roda as migrations na primeira abertura.
*/
export async function getDatabase(): Promise<SQLiteDatabase> {
if (_db) return _db;
_db = await openDatabaseAsync(LOCAL_DB_NAME);
await runMigrations(_db);
return _db;
}
async function runMigrations(db: SQLiteDatabase): Promise<void> {
await db.execAsync(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS spool_presets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
spool_weight_g INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
user_id TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS filaments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
material TEXT NOT NULL,
brand TEXT NOT NULL,
model TEXT,
color_hex TEXT NOT NULL,
spool_preset_id TEXT NOT NULL,
total_weight_g INTEGER NOT NULL,
net_weight_g INTEGER NOT NULL,
temp_hotend_c INTEGER,
temp_bed_c INTEGER,
flow_factor_pct REAL,
notes TEXT,
updated_at TEXT NOT NULL,
created_at TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (spool_preset_id) REFERENCES spool_presets(id)
);
CREATE TABLE IF NOT EXISTS sync_queue (
id TEXT PRIMARY KEY,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
}
@@ -0,0 +1,65 @@
import { httpClient } from './httpClient';
import type { AuthRepository } from '@ports/AuthRepository';
import type { AuthSession, LoginInput, RegisterInput, GoogleOAuthInput } from '@domain/User';
/**
* Implementação remota do AuthRepository.
* Consome os endpoints /api/v1/auth/* do backend Rust.
*/
export class ApiAuthRepository implements AuthRepository {
async login(input: LoginInput): Promise<AuthSession> {
const { data } = await httpClient.post('/auth/login', input);
return this.mapSession(data);
}
async register(input: RegisterInput): Promise<AuthSession> {
const { data } = await httpClient.post('/auth/register', input);
return this.mapSession(data);
}
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
const { data } = await httpClient.post('/auth/oauth/google', input);
return this.mapSession(data);
}
async refreshToken(refreshToken: string): Promise<Pick<AuthSession, 'accessToken' | 'refreshToken'>> {
const { data } = await httpClient.post('/auth/refresh', { refreshToken });
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
}
async logout(accessToken: string): Promise<void> {
await httpClient.post('/auth/logout', {}, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
async forgotPassword(email: string): Promise<void> {
await httpClient.post('/auth/forgot-password', { email });
}
async verifyEmail(token: string): Promise<void> {
await httpClient.post('/auth/verify-email', { token });
}
async resetPassword(token: string, newPassword: string): Promise<void> {
await httpClient.post('/auth/reset-password', { token, new_password: newPassword });
}
private mapSession(data: Record<string, unknown>): AuthSession {
const user = data.user as Record<string, unknown>;
return {
accessToken: data.access_token as string,
refreshToken: data.refresh_token as string,
user: {
id: user.id as string,
email: user.email as string,
name: (user.name as string | null) ?? null,
googleId: (user.google_id as string | null) ?? null,
createdAt: user.created_at as string,
},
};
}
}
@@ -0,0 +1,93 @@
import { httpClient } from './httpClient';
import type { FilamentRepository } from '@ports/FilamentRepository';
import type { Filament, CreateFilamentInput, UpdateFilamentInput, FilamentFilter } from '@domain/Filament';
function mapFilament(d: Record<string, unknown>): Filament {
return {
id: d.id as string,
userId: d.user_id as string,
material: d.material as Filament['material'],
brand: d.brand as string,
model: (d.model as string | null) ?? null,
colorHex: d.color_hex as string,
spoolPresetId: d.spool_preset_id as string,
totalWeightG: d.total_weight_g as number,
netWeightG: d.net_weight_g as number,
tempHotendC: (d.temp_hotend_c as number | null) ?? null,
tempBedC: (d.temp_bed_c as number | null) ?? null,
flowFactorPct: (d.flow_factor_pct as number | null) ?? null,
notes: (d.notes as string | null) ?? null,
updatedAt: d.updated_at as string,
createdAt: d.created_at as string,
};
}
/**
* Implementação remota do FilamentRepository.
* Consome os endpoints /api/v1/filaments/* do backend Rust.
*/
export class ApiFilamentRepository implements FilamentRepository {
async findById(id: string): Promise<Filament | null> {
try {
const { data } = await httpClient.get(`/filaments/${id}`);
return mapFilament(data);
} catch (err: unknown) {
if ((err as { response?: { status?: number } }).response?.status === 404) return null;
throw err;
}
}
async list(_userId: string, filter?: FilamentFilter): Promise<Filament[]> {
const params: Record<string, unknown> = {};
if (filter?.material) params.material = filter.material;
if (filter?.brand) params.brand = filter.brand;
if (filter?.search) params.search = filter.search;
if (filter?.stockLevel) params.stock_level = filter.stockLevel;
if (filter?.sortBy) params.sort = filter.sortBy;
if (filter?.page) params.page = filter.page;
if (filter?.perPage) params.per_page = filter.perPage;
const { data } = await httpClient.get('/filaments', { params });
return (data as Record<string, unknown>[]).map(mapFilament);
}
async create(
_userId: string,
input: CreateFilamentInput & { netWeightG: number },
): Promise<Filament> {
const { data } = await httpClient.post('/filaments', {
material: input.material,
brand: input.brand,
model: input.model,
color_hex: input.colorHex,
spool_preset_id: input.spoolPresetId,
total_weight_g: input.totalWeightG,
temp_hotend_c: input.tempHotendC,
temp_bed_c: input.tempBedC,
flow_factor_pct: input.flowFactorPct,
notes: input.notes,
});
return mapFilament(data);
}
async update(id: string, _userId: string, input: UpdateFilamentInput & { netWeightG?: number }): Promise<Filament> {
const body: Record<string, unknown> = {};
if (input.material) body.material = input.material;
if (input.brand) body.brand = input.brand;
if (input.model !== undefined) body.model = input.model;
if (input.colorHex) body.color_hex = input.colorHex;
if (input.spoolPresetId) body.spool_preset_id = input.spoolPresetId;
if (input.totalWeightG !== undefined) body.total_weight_g = input.totalWeightG;
if (input.tempHotendC !== undefined) body.temp_hotend_c = input.tempHotendC;
if (input.tempBedC !== undefined) body.temp_bed_c = input.tempBedC;
if (input.flowFactorPct !== undefined) body.flow_factor_pct = input.flowFactorPct;
if (input.notes !== undefined) body.notes = input.notes;
const { data } = await httpClient.put(`/filaments/${id}`, body);
return mapFilament(data);
}
async remove(id: string): Promise<void> {
await httpClient.delete(`/filaments/${id}`);
}
}
@@ -0,0 +1,55 @@
import { httpClient } from './httpClient';
import type { SpoolPresetRepository } from '@ports/SpoolPresetRepository';
import type { SpoolPreset, CreateSpoolPresetInput, UpdateSpoolPresetInput } from '@domain/SpoolPreset';
function mapPreset(d: Record<string, unknown>): SpoolPreset {
return {
id: d.id as string,
name: d.name as string,
spoolWeightG: d.spool_weight_g as number,
isSystem: d.is_system as boolean,
userId: (d.user_id as string | null) ?? null,
createdAt: d.created_at as string,
};
}
/**
* Implementação remota do SpoolPresetRepository.
* Consome os endpoints /api/v1/spool-presets/* do backend Rust.
*/
export class ApiSpoolPresetRepository implements SpoolPresetRepository {
async list(): Promise<SpoolPreset[]> {
const { data } = await httpClient.get('/spool-presets');
return (data as Record<string, unknown>[]).map(mapPreset);
}
async findById(id: string): Promise<SpoolPreset | null> {
try {
const { data } = await httpClient.get(`/spool-presets/${id}`);
return mapPreset(data);
} catch (err: unknown) {
if ((err as { response?: { status?: number } }).response?.status === 404) return null;
throw err;
}
}
async create(_userId: string, input: CreateSpoolPresetInput): Promise<SpoolPreset> {
const { data } = await httpClient.post('/spool-presets', {
name: input.name,
spool_weight_g: input.spoolWeightG,
});
return mapPreset(data);
}
async update(id: string, _userId: string, input: UpdateSpoolPresetInput): Promise<SpoolPreset> {
const body: Record<string, unknown> = {};
if (input.name) body.name = input.name;
if (input.spoolWeightG !== undefined) body.spool_weight_g = input.spoolWeightG;
const { data } = await httpClient.put(`/spool-presets/${id}`, body);
return mapPreset(data);
}
async remove(id: string): Promise<void> {
await httpClient.delete(`/spool-presets/${id}`);
}
}
+90
View File
@@ -0,0 +1,90 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
import * as SecureStore from 'expo-secure-store';
import { API_BASE_URL, SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN } from '@shared/constants';
/**
* Cliente HTTP centralizado.
*
* - Injeta o Bearer token automaticamente em todas as requisições.
* - Intercepta 401 para tentar refresh do token automaticamente.
* - Garante que todas as chamadas apontem para a base URL da API.
*/
const httpClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 15_000,
headers: {
'Content-Type': 'application/json',
},
});
// ── Interceptor de request: anexa Bearer token ──────────────
httpClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ── Interceptor de response: refresh automático em 401 ──────
let isRefreshing = false;
let pendingQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null): void {
pendingQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else if (token) resolve(token);
});
pendingQueue = [];
}
httpClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return httpClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = await SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN);
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post(`${API_BASE_URL}/auth/refresh`, { refreshToken });
await SecureStore.setItemAsync(SECURE_STORE_ACCESS_TOKEN, data.access_token);
await SecureStore.setItemAsync(SECURE_STORE_REFRESH_TOKEN, data.refresh_token);
processQueue(null, data.access_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return httpClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
// Limpa tokens inválidos — a store de auth detecta e redireciona para login
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
export { httpClient };
@@ -0,0 +1,80 @@
import type { AuthRepository } from '@ports/AuthRepository';
import type { AuthSession, LoginInput, RegisterInput, GoogleOAuthInput } from '@domain/User';
/**
* Caso de uso: Login com e-mail/senha.
*/
export class LoginUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(input: LoginInput): Promise<AuthSession> {
if (!input.email || !input.password) {
throw new Error('E-mail e senha são obrigatórios.');
}
return this.authRepo.login(input);
}
}
/**
* Caso de uso: Cadastro de novo usuário.
*/
export class RegisterUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(input: RegisterInput): Promise<AuthSession> {
if (!input.email || !input.password) {
throw new Error('E-mail e senha são obrigatórios.');
}
if (input.password.length < 8) {
throw new Error('A senha deve ter ao menos 8 caracteres.');
}
return this.authRepo.register(input);
}
}
/**
* Caso de uso: Login via OAuth Google.
*/
export class GoogleLoginUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(input: GoogleOAuthInput): Promise<AuthSession> {
return this.authRepo.loginWithGoogle(input);
}
}
/**
* Caso de uso: Logout.
*/
export class LogoutUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(accessToken: string): Promise<void> {
await this.authRepo.logout(accessToken);
}
}
/**
* Caso de uso: Solicitar redefinição de senha.
*/
export class ForgotPasswordUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(email: string): Promise<void> {
await this.authRepo.forgotPassword(email);
}
}
/**
* Caso de uso: Redefinir senha com token.
*/
export class ResetPasswordUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(token: string, newPassword: string): Promise<void> {
if (newPassword.length < 8) {
throw new Error('A nova senha deve ter ao menos 8 caracteres.');
}
await this.authRepo.resetPassword(token, newPassword);
}
}
@@ -0,0 +1,28 @@
import type { FilamentRepository } from '@ports/FilamentRepository';
import type { SpoolPresetRepository } from '@ports/SpoolPresetRepository';
import type { Filament, CreateFilamentInput } from '@domain/Filament';
import { calcNetWeight } from '@domain/Filament';
/**
* Caso de uso: Criar Filamento.
*
* Responsabilidade: buscar o preset selecionado, calcular o peso líquido
* e persistir o filamento via repositório.
*/
export class CreateFilamentUseCase {
constructor(
private readonly filamentRepo: FilamentRepository,
private readonly presetRepo: SpoolPresetRepository,
) {}
async execute(userId: string, input: CreateFilamentInput): Promise<Filament> {
const preset = await this.presetRepo.findById(input.spoolPresetId);
if (!preset) {
throw new Error(`Preset de carretel não encontrado: ${input.spoolPresetId}`);
}
const netWeightG = calcNetWeight(input.totalWeightG, preset.spoolWeightG);
return this.filamentRepo.create(userId, { ...input, netWeightG });
}
}
@@ -0,0 +1,16 @@
import type { FilamentRepository } from '@ports/FilamentRepository';
/**
* Caso de uso: Deletar Filamento.
*/
export class DeleteFilamentUseCase {
constructor(private readonly filamentRepo: FilamentRepository) {}
async execute(id: string, userId: string): Promise<void> {
const existing = await this.filamentRepo.findById(id, userId);
if (!existing) {
throw new Error(`Filamento não encontrado: ${id}`);
}
await this.filamentRepo.remove(id, userId);
}
}
@@ -0,0 +1,13 @@
import type { FilamentRepository } from '@ports/FilamentRepository';
import type { Filament, FilamentFilter } from '@domain/Filament';
/**
* Caso de uso: Listar Filamentos.
*/
export class ListFilamentsUseCase {
constructor(private readonly filamentRepo: FilamentRepository) {}
async execute(userId: string, filter?: FilamentFilter): Promise<Filament[]> {
return this.filamentRepo.list(userId, filter);
}
}
@@ -0,0 +1,40 @@
import type { FilamentRepository } from '@ports/FilamentRepository';
import type { SpoolPresetRepository } from '@ports/SpoolPresetRepository';
import type { Filament, UpdateFilamentInput } from '@domain/Filament';
import { calcNetWeight } from '@domain/Filament';
/**
* Caso de uso: Atualizar Filamento.
*
* Se o peso total ou o preset mudaram, recalcula o peso líquido.
*/
export class UpdateFilamentUseCase {
constructor(
private readonly filamentRepo: FilamentRepository,
private readonly presetRepo: SpoolPresetRepository,
) {}
async execute(id: string, userId: string, input: UpdateFilamentInput): Promise<Filament> {
const existing = await this.filamentRepo.findById(id, userId);
if (!existing) {
throw new Error(`Filamento não encontrado: ${id}`);
}
let netWeightG = existing.netWeightG;
const totalChanged = input.totalWeightG !== undefined;
const presetChanged = input.spoolPresetId !== undefined;
if (totalChanged || presetChanged) {
const presetId = input.spoolPresetId ?? existing.spoolPresetId;
const preset = await this.presetRepo.findById(presetId);
if (!preset) {
throw new Error(`Preset de carretel não encontrado: ${presetId}`);
}
const totalWeight = input.totalWeightG ?? existing.totalWeightG;
netWeightG = calcNetWeight(totalWeight, preset.spoolWeightG);
}
return this.filamentRepo.update(id, userId, { ...input, netWeightG });
}
}
@@ -0,0 +1,60 @@
import type { SpoolPresetRepository } from '@ports/SpoolPresetRepository';
import type { SpoolPreset, CreateSpoolPresetInput, UpdateSpoolPresetInput } from '@domain/SpoolPreset';
import { isUserOwnedPreset } from '@domain/SpoolPreset';
/**
* Caso de uso: Listar Presets de Carretéis.
* Retorna presets do sistema + presets do usuário.
*/
export class ListPresetsUseCase {
constructor(private readonly presetRepo: SpoolPresetRepository) {}
async execute(userId: string): Promise<SpoolPreset[]> {
return this.presetRepo.list(userId);
}
}
/**
* Caso de uso: Criar Preset Customizado.
*/
export class CreatePresetUseCase {
constructor(private readonly presetRepo: SpoolPresetRepository) {}
async execute(userId: string, input: CreateSpoolPresetInput): Promise<SpoolPreset> {
return this.presetRepo.create(userId, input);
}
}
/**
* Caso de uso: Atualizar Preset.
* Garante que apenas presets customizados do usuário sejam editáveis.
*/
export class UpdatePresetUseCase {
constructor(private readonly presetRepo: SpoolPresetRepository) {}
async execute(id: string, userId: string, input: UpdateSpoolPresetInput): Promise<SpoolPreset> {
const existing = await this.presetRepo.findById(id);
if (!existing) throw new Error(`Preset não encontrado: ${id}`);
if (!isUserOwnedPreset(existing)) {
throw new Error('Presets do sistema não podem ser editados.');
}
return this.presetRepo.update(id, userId, input);
}
}
/**
* Caso de uso: Deletar Preset.
* Garante que apenas presets customizados do usuário sejam deletáveis.
*/
export class DeletePresetUseCase {
constructor(private readonly presetRepo: SpoolPresetRepository) {}
async execute(id: string, userId: string): Promise<void> {
const existing = await this.presetRepo.findById(id);
if (!existing) throw new Error(`Preset não encontrado: ${id}`);
if (!isUserOwnedPreset(existing)) {
throw new Error('Presets do sistema não podem ser excluídos.');
}
await this.presetRepo.remove(id, userId);
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Dados agregados do dashboard.
* Construído a partir dos filamentos do usuário.
*/
export interface DashboardData {
totalStockKg: number;
lowStockCount: number;
byMaterial: MaterialSummary[];
lowStockFilaments: LowStockFilament[];
recentFilaments: RecentFilament[];
}
export interface MaterialSummary {
material: string;
count: number;
totalKg: number;
}
export interface LowStockFilament {
id: string;
name: string; // ex: "PETG White"
brand: string;
material: string;
netWeightG: number;
percentage: number;
colorHex: string;
}
export interface RecentFilament {
id: string;
name: string;
brand: string;
material: string;
netWeightG: number;
percentage: number;
colorHex: string;
}
+65
View File
@@ -0,0 +1,65 @@
import type { Material } from '@shared/constants';
/**
* Entidade de domínio: Filamento.
*
* Representa um rolo de filamento no inventário do usuário.
* Esta é uma entidade pura — sem dependências de UI, API ou banco de dados.
*/
export interface Filament {
readonly id: string;
readonly userId: string;
material: Material;
brand: string;
model: string | null;
colorHex: string;
spoolPresetId: string;
totalWeightG: number;
netWeightG: number;
tempHotendC: number | null;
tempBedC: number | null;
flowFactorPct: number | null;
notes: string | null;
readonly updatedAt: string; // ISO 8601 — usado no sync offline
readonly createdAt: string;
}
/**
* DTO para criação de um novo filamento.
* Campos calculados (netWeightG) são omitidos — derivados no use case.
*/
export type CreateFilamentInput = Omit<Filament, 'id' | 'userId' | 'netWeightG' | 'createdAt' | 'updatedAt'>;
/**
* DTO para atualização de um filamento existente.
*/
export type UpdateFilamentInput = Partial<CreateFilamentInput>;
/**
* Filtros disponíveis na listagem de filamentos.
*/
export interface FilamentFilter {
material?: Material;
brand?: string;
search?: string;
stockLevel?: 'low' | 'medium' | 'ok';
sortBy?: 'net_weight_asc' | 'net_weight_desc' | 'created_at_desc';
page?: number;
perPage?: number;
}
/**
* Calcula a porcentagem de filamento disponível em relação ao peso total
* de um rolo cheio (1000g por convenção).
*/
export function calcFilamentPercentage(filament: Filament): number {
const base = 1000;
return Math.min(100, Math.max(0, Math.round((filament.netWeightG / base) * 100)));
}
/**
* Calcula o peso líquido dado o peso total e o peso do carretel vazio.
*/
export function calcNetWeight(totalWeightG: number, spoolWeightG: number): number {
return Math.max(0, totalWeightG - spoolWeightG);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Entidade de domínio: Preset de Carretel.
*
* Representa o peso do carretel vazio de uma marca/modelo.
* Presets do sistema (isSystem = true) são somente leitura.
*/
export interface SpoolPreset {
readonly id: string;
name: string;
spoolWeightG: number;
readonly isSystem: boolean;
readonly userId: string | null; // null para presets do sistema
readonly createdAt: string;
}
/**
* DTO para criação de um preset customizado.
*/
export type CreateSpoolPresetInput = Pick<SpoolPreset, 'name' | 'spoolWeightG'>;
/**
* DTO para atualização de um preset customizado.
*/
export type UpdateSpoolPresetInput = Partial<CreateSpoolPresetInput>;
/**
* Guard: retorna true se o preset pode ser editado/deletado pelo usuário.
*/
export function isUserOwnedPreset(preset: SpoolPreset): boolean {
return !preset.isSystem && preset.userId !== null;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Entidade de domínio: Usuário.
*
* Representa o usuário autenticado na sessão.
*/
export interface User {
readonly id: string;
email: string;
name: string | null;
readonly googleId: string | null;
readonly createdAt: string;
}
/**
* Sessão de autenticação persistida localmente.
*/
export interface AuthSession {
readonly accessToken: string;
readonly refreshToken: string;
readonly user: User;
}
/**
* DTO para login com e-mail/senha.
*/
export interface LoginInput {
email: string;
password: string;
}
/**
* DTO para cadastro.
*/
export interface RegisterInput {
email: string;
password: string;
}
/**
* DTO para autenticação via OAuth Google.
*/
export interface GoogleOAuthInput {
idToken: string;
}
+16
View File
@@ -0,0 +1,16 @@
import type { AuthSession, LoginInput, RegisterInput, GoogleOAuthInput } from '@domain/User';
/**
* Contrato para o repositório de autenticação.
* Abstrai o transporte (REST) das operações de auth.
*/
export interface AuthRepository {
login(input: LoginInput): Promise<AuthSession>;
register(input: RegisterInput): Promise<AuthSession>;
loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession>;
refreshToken(refreshToken: string): Promise<Pick<AuthSession, 'accessToken' | 'refreshToken'>>;
logout(accessToken: string): Promise<void>;
forgotPassword(email: string): Promise<void>;
verifyEmail(token: string): Promise<void>;
resetPassword(token: string, newPassword: string): Promise<void>;
}
+13
View File
@@ -0,0 +1,13 @@
import type { Filament, CreateFilamentInput, UpdateFilamentInput, FilamentFilter } from '@domain/Filament';
/**
* Contrato que qualquer repositório de filamentos deve implementar.
* Tanto o adapter local (SQLite) quanto o remoto (API) implementam esta interface.
*/
export interface FilamentRepository {
findById(id: string, userId: string): Promise<Filament | null>;
list(userId: string, filter?: FilamentFilter): Promise<Filament[]>;
create(userId: string, input: CreateFilamentInput & { netWeightG: number }): Promise<Filament>;
update(id: string, userId: string, input: UpdateFilamentInput & { netWeightG?: number }): Promise<Filament>;
remove(id: string, userId: string): Promise<void>;
}
+12
View File
@@ -0,0 +1,12 @@
import type { SpoolPreset, CreateSpoolPresetInput, UpdateSpoolPresetInput } from '@domain/SpoolPreset';
/**
* Contrato que qualquer repositório de presets de carretéis deve implementar.
*/
export interface SpoolPresetRepository {
list(userId: string): Promise<SpoolPreset[]>;
findById(id: string): Promise<SpoolPreset | null>;
create(userId: string, input: CreateSpoolPresetInput): Promise<SpoolPreset>;
update(id: string, userId: string, input: UpdateSpoolPresetInput): Promise<SpoolPreset>;
remove(id: string, userId: string): Promise<void>;
}
@@ -0,0 +1,40 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { colors, radius } from '@shared/theme';
interface ColorSwatchProps {
colorHex: string;
size?: number;
borderRadius?: number;
}
/**
* Swatch de cor do filamento.
* Exibe um quadrado (ou círculo) com a cor hex do filamento.
*/
export function ColorSwatch({
colorHex,
size = 48,
borderRadius,
}: ColorSwatchProps): React.ReactElement {
return (
<View
style={[
styles.swatch,
{
width: size,
height: size,
backgroundColor: colorHex,
borderRadius: borderRadius ?? Math.round(size * 0.2),
},
]}
/>
);
}
const styles = StyleSheet.create({
swatch: {
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
},
});
@@ -0,0 +1,88 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import type { Filament } from '@domain/Filament';
import { calcFilamentPercentage } from '@domain/Filament';
import { ColorSwatch } from './ColorSwatch';
import { StockBadge } from './StockBar';
import { colors, typography, spacing, radius } from '@shared/theme';
import { formatWeight } from '@shared/utils/filament';
interface FilamentCardProps {
filament: Filament;
}
/**
* Card de filamento usado na listagem do Inventário.
* Exibe: swatch de cor, nome (modelo), marca, material, temperatura, peso e badge de %.
*/
export function FilamentCard({ filament }: FilamentCardProps): React.ReactElement {
const router = useRouter();
const percentage = calcFilamentPercentage(filament);
const name = filament.model ?? filament.material;
function handlePress(): void {
router.push(`/inventory/${filament.id}`);
}
return (
<TouchableOpacity
activeOpacity={0.75}
onPress={handlePress}
style={styles.card}
>
<ColorSwatch colorHex={filament.colorHex} size={48} />
<View style={styles.info}>
<Text style={styles.name}>{name}</Text>
<Text style={styles.meta}>
{filament.brand} · {filament.material}
{filament.tempHotendC ? ` · ${filament.tempHotendC}°C` : ''}
</Text>
</View>
<View style={styles.right}>
<Text style={styles.weight}>{formatWeight(filament.netWeightG)}</Text>
<StockBadge percentage={percentage} />
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
backgroundColor: colors.bgSurface,
borderRadius: radius.lg,
padding: spacing[4],
borderWidth: 1,
borderColor: colors.border,
},
info: {
flex: 1,
gap: 2,
},
name: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
meta: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
right: {
alignItems: 'flex-end',
gap: spacing[1],
},
weight: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
},
});
@@ -0,0 +1,68 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, typography, radius, spacing } from '@shared/theme';
import { getStockColor } from '@shared/theme';
interface StockBarProps {
percentage: number;
height?: number;
}
/**
* Barra de progresso de estoque.
* Muda de cor baseado nos limiares definidos no PRD (15% / 35%).
*/
export function StockBar({ percentage, height = 4 }: StockBarProps): React.ReactElement {
const color = getStockColor(percentage);
const width = `${Math.min(100, Math.max(0, percentage))}%`;
return (
<View style={[styles.track, { height }]}>
<View style={{ ...styles.fill, width: width as `${number}%`, backgroundColor: color, height }} />
</View>
);
}
interface StockBadgeProps {
percentage: number;
}
/**
* Badge numérico de estoque com cor dinâmica.
*/
export function StockBadge({ percentage }: StockBadgeProps): React.ReactElement {
const color = getStockColor(percentage);
const bg = percentage <= 15
? 'rgba(255,107,107,0.15)'
: percentage <= 35
? 'rgba(255,159,67,0.15)'
: 'rgba(56,188,194,0.15)';
return (
<View style={[styles.badge, { backgroundColor: bg }]}>
<Text style={[styles.badgeText, { color }]}>{percentage}%</Text>
</View>
);
}
const styles = StyleSheet.create({
track: {
width: '100%',
backgroundColor: colors.bgHover,
borderRadius: radius.full,
overflow: 'hidden',
},
fill: {
borderRadius: radius.full,
},
badge: {
paddingHorizontal: spacing[2],
paddingVertical: 3,
borderRadius: radius.sm,
},
badgeText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
},
});
@@ -0,0 +1,75 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { colors, typography, spacing } from '@shared/theme';
interface HeaderProps {
title: string;
/** Mostra botão de voltar. Padrão: false */
showBack?: boolean;
/** Ação customizada para o botão de voltar */
onBack?: () => void;
rightAction?: React.ReactNode;
}
/**
* Header de tela padrão do MeowSpool.
* - Título centralizado
* - Botão de voltar opcional (usa expo-router)
* - Slot para ação à direita (ícone, botão, etc.)
*/
export function Header({ title, showBack = false, onBack, rightAction }: HeaderProps): React.ReactElement {
const router = useRouter();
function handleBack(): void {
if (onBack) {
onBack();
} else {
router.back();
}
}
return (
<View style={styles.header}>
<View style={styles.left}>
{showBack ? (
<TouchableOpacity onPress={handleBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</TouchableOpacity>
) : null}
</View>
<Text style={styles.title}>{title}</Text>
<View style={styles.right}>{rightAction ?? null}</View>
</View>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing[5],
paddingVertical: spacing[4],
minHeight: 56,
},
left: {
width: 40,
alignItems: 'flex-start',
},
title: {
flex: 1,
textAlign: 'center',
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.textPrimary,
},
right: {
width: 40,
alignItems: 'flex-end',
},
});
@@ -0,0 +1,87 @@
import React from 'react';
import {
SafeAreaView,
ScrollView,
KeyboardAvoidingView,
View,
Platform,
StyleSheet,
type ViewStyle,
} from 'react-native';
import { colors, spacing } from '@shared/theme';
interface ScreenProps {
children: React.ReactNode;
/** Permite scroll vertical. Padrão: false */
scrollable?: boolean;
/** Evita que o teclado cubra campos de formulário. Padrão: false */
keyboardAvoiding?: boolean;
/** Padding horizontal. Padrão: 20 */
horizontalPadding?: number;
style?: ViewStyle;
contentStyle?: ViewStyle;
}
/**
* Wrapper de tela base.
* Aplica SafeArea, cor de fundo e opcionalmente scroll/teclado.
*/
export function Screen({
children,
scrollable = false,
keyboardAvoiding = false,
horizontalPadding = spacing[5],
style,
contentStyle,
}: ScreenProps): React.ReactElement {
const inner = scrollable ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{ paddingHorizontal: horizontalPadding },
contentStyle,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={[styles.flex, { paddingHorizontal: horizontalPadding }, contentStyle]}>
{children}
</View>
);
const content = keyboardAvoiding ? (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
{inner}
</KeyboardAvoidingView>
) : (
inner
);
return <SafeAreaView style={[styles.safeArea, style]}>{content}</SafeAreaView>;
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.bgBase,
},
flex: {
flex: 1,
},
scroll: {
flex: 1,
backgroundColor: colors.bgBase,
},
scrollContent: {
flexGrow: 1,
paddingBottom: spacing[10],
},
});
@@ -0,0 +1,41 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, typography, radius, spacing } from '@shared/theme';
import { getStockColor, getStockBgColor } from '@shared/theme';
interface BadgeProps {
label: string;
/** Se fornecido, a cor muda de acordo com o limiar de estoque */
stockPercentage?: number;
color?: string;
bgColor?: string;
}
/**
* Badge genérico do MeowSpool.
* Usado para material, percentual de estoque e status.
*/
export function Badge({ label, stockPercentage, color, bgColor }: BadgeProps): React.ReactElement {
const resolvedColor = stockPercentage !== undefined ? getStockColor(stockPercentage) : (color ?? colors.accent);
const resolvedBg = stockPercentage !== undefined ? getStockBgColor(stockPercentage) : (bgColor ?? colors.accentMuted);
return (
<View style={[styles.badge, { backgroundColor: resolvedBg }]}>
<Text style={[styles.label, { color: resolvedColor }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: spacing[2],
paddingVertical: 3,
borderRadius: radius.sm,
alignSelf: 'flex-start',
},
label: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
},
});
@@ -0,0 +1,106 @@
import React from 'react';
import {
TouchableOpacity,
Text,
ActivityIndicator,
StyleSheet,
type TouchableOpacityProps,
} from 'react-native';
import { colors, typography, radius, spacing } from '@shared/theme';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
interface ButtonProps extends TouchableOpacityProps {
label: string;
variant?: Variant;
isLoading?: boolean;
leftIcon?: React.ReactNode;
}
/**
* Botão base do MeowSpool.
* - primary: fundo accent (#38BCC2), texto escuro — ação principal
* - secondary: fundo surface com borda — ação secundária
* - ghost: sem fundo — ação terciária / links
* - danger: fundo vermelho translúcido — ações destrutivas
*/
export function Button({
label,
variant = 'primary',
isLoading = false,
leftIcon,
disabled,
style,
...props
}: ButtonProps): React.ReactElement {
const isDisabled = disabled || isLoading;
return (
<TouchableOpacity
activeOpacity={0.8}
disabled={isDisabled}
style={[styles.base, styles[variant], isDisabled && styles.disabled, style]}
{...props}
>
{isLoading ? (
<ActivityIndicator color={variant === 'primary' ? colors.bgBase : colors.accent} />
) : (
<>
{leftIcon}
<Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
</>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing[2],
borderRadius: radius.lg,
paddingVertical: spacing[4],
paddingHorizontal: spacing[6],
minHeight: 52,
},
// ── Variantes ───────────────────────────────────────────────
primary: {
backgroundColor: colors.accent,
},
secondary: {
backgroundColor: colors.bgSurface,
borderWidth: 1,
borderColor: colors.border,
},
ghost: {
backgroundColor: colors.transparent,
},
danger: {
backgroundColor: 'rgba(255,107,107,0.12)',
borderWidth: 1,
borderColor: colors.error,
},
disabled: {
opacity: 0.4,
},
// ── Labels ──────────────────────────────────────────────────
label: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
},
primaryLabel: {
color: colors.bgBase,
},
secondaryLabel: {
color: colors.textPrimary,
},
ghostLabel: {
color: colors.accent,
},
dangerLabel: {
color: colors.error,
},
});
@@ -0,0 +1,28 @@
import React from 'react';
import { View, StyleSheet, type ViewProps } from 'react-native';
import { colors, radius, spacing } from '@shared/theme';
interface CardProps extends ViewProps {
children: React.ReactNode;
padding?: number;
}
/**
* Container card com fundo surface e borda sutil.
*/
export function Card({ children, padding = spacing[4], style, ...props }: CardProps): React.ReactElement {
return (
<View style={[styles.card, { padding }, style]} {...props}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.bgSurface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
},
});
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import {
View,
TextInput,
Text,
TouchableOpacity,
StyleSheet,
type TextInputProps,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors, typography, radius, spacing } from '@shared/theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
leftIcon?: React.ReactNode;
isPassword?: boolean;
/** Text suffix rendered to the right of the input (e.g. "g" for grams) */
rightLabel?: string;
}
/**
* Campo de entrada base do MeowSpool.
* Suporta label, ícone à esquerda, toggle de senha e mensagem de erro.
*/
export function Input({
label,
error,
leftIcon,
isPassword = false,
rightLabel,
style,
...props
}: InputProps): React.ReactElement {
const [isVisible, setIsVisible] = useState(false);
return (
<View style={styles.wrapper}>
{label ? <Text style={styles.label}>{label}</Text> : null}
<View style={[styles.container, error ? styles.containerError : null]}>
{leftIcon ? <View style={styles.iconLeft}>{leftIcon}</View> : null}
<TextInput
style={[styles.input, style]}
placeholderTextColor={colors.textSecondary}
secureTextEntry={isPassword && !isVisible}
autoCapitalize="none"
{...props}
/>
{isPassword ? (
<TouchableOpacity
onPress={() => setIsVisible((v) => !v)}
style={styles.iconRight}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons
name={isVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
) : null}
{rightLabel && !isPassword ? (
<Text style={styles.rightLabel}>{rightLabel}</Text>
) : null}
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing[2],
},
label: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgSurface,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing[4],
minHeight: 52,
},
containerError: {
borderColor: colors.error,
},
iconLeft: {
marginRight: spacing[3],
},
iconRight: {
marginLeft: spacing[2],
},
input: {
flex: 1,
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textPrimary,
paddingVertical: spacing[4],
},
error: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xs,
color: colors.error,
},
rightLabel: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
marginLeft: spacing[2],
},
});
+32
View File
@@ -0,0 +1,32 @@
/** Constantes globais da aplicação */
export const APP_NAME = 'MeowSpool';
export const APP_SCHEME = 'meowspool';
/** URL base da API — substituída por variável de ambiente em produção */
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api/v1';
/** Chave usada no SecureStore para o access token JWT */
export const SECURE_STORE_ACCESS_TOKEN = 'meowspool_access_token';
/** Chave usada no SecureStore para o refresh token */
export const SECURE_STORE_REFRESH_TOKEN = 'meowspool_refresh_token';
/** Nome do banco SQLite local */
export const LOCAL_DB_NAME = 'meowspool.db';
/** Limiar de estoque baixo (%) */
export const STOCK_THRESHOLD_LOW = 15;
/** Limiar de estoque médio (%) */
export const STOCK_THRESHOLD_MEDIUM = 35;
/** Peso do rolo padrão para calcular % (1000g) */
export const DEFAULT_SPOOL_TOTAL_WEIGHT_G = 1000;
/** Tamanho padrão da etiqueta SVG exportada */
export const LABEL_DEFAULT_WIDTH_MM = 50;
export const LABEL_DEFAULT_HEIGHT_MM = 30;
export const MATERIALS = ['PLA', 'ABS', 'PETG', 'TPU', 'ASA', 'PA', 'PC'] as const;
export type Material = (typeof MATERIALS)[number];
+140
View File
@@ -0,0 +1,140 @@
/**
* MeowSpool Design Tokens
*
* Paleta siamês: tons quentes/escuros com accent azul-aço
* inspirados nos olhos do gato siamês.
*
* Fonte única de verdade para cores, tipografia e espaçamento.
*/
export const colors = {
// ── Backgrounds ────────────────────────────────────────────
bgBase: '#1E1B18', // Fundo geral
bgSurface: '#2A2622', // Cards, containers, widgets
bgHover: '#332F2B', // Hover em cards e itens de lista
// ── Textos ─────────────────────────────────────────────────
textPrimary: '#F5EEDC', // Títulos, peso líquido
textSecondary: '#C9C1B0',// Descrições, labels
// ── Accent ─────────────────────────────────────────────────
accent: '#38BCC2', // Botões de ação, elementos ativos
accentMuted: '#38BCC226',// Backgrounds de badges (10% opacidade)
// ── Estoque (alertas) ───────────────────────────────────────
stockLow: '#FF6B6B', // ≤ 15% — vermelho
stockMedium: '#FF9F43', // ≤ 35% — laranja
stockOk: '#38BCC2', // > 35% — accent
// ── Utilitários ─────────────────────────────────────────────
white: '#FFFFFF',
black: '#000000',
transparent: 'transparent',
border: '#3D3830', // Bordas sutis
overlay: 'rgba(0,0,0,0.6)',
error: '#FF6B6B',
success: '#4CAF50',
} as const;
export const typography = {
// ── Famílias ────────────────────────────────────────────────
fontFamily: {
ui: 'Inter', // Texto geral da interface
mono: 'JetBrains Mono', // URLs, slugs, código
},
// ── Tamanhos ────────────────────────────────────────────────
fontSize: {
xs: 11,
sm: 13,
base: 15,
md: 17,
lg: 20,
xl: 24,
'2xl': 28,
'3xl': 34,
},
// ── Pesos ───────────────────────────────────────────────────
fontWeight: {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
},
// ── Alturas de linha ────────────────────────────────────────
lineHeight: {
tight: 1.2,
normal: 1.5,
relaxed: 1.7,
},
} as const;
export const spacing = {
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
7: 28,
8: 32,
10: 40,
12: 48,
16: 64,
} as const;
export const radius = {
sm: 6,
md: 10,
lg: 14,
xl: 20,
full: 9999,
} as const;
export const shadows = {
sm: {
shadowColor: colors.black,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 3,
elevation: 2,
},
md: {
shadowColor: colors.black,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
},
} as const;
/**
* Retorna a cor de alerta de estoque baseada na porcentagem.
*/
export function getStockColor(percentage: number): string {
if (percentage <= 15) return colors.stockLow;
if (percentage <= 35) return colors.stockMedium;
return colors.stockOk;
}
/**
* Retorna a cor de background muted do alerta baseado na porcentagem.
*/
export function getStockBgColor(percentage: number): string {
if (percentage <= 15) return 'rgba(255,107,107,0.15)';
if (percentage <= 35) return 'rgba(255,159,67,0.15)';
return colors.accentMuted;
}
export const theme = {
colors,
typography,
spacing,
radius,
shadows,
} as const;
export type Theme = typeof theme;
+49
View File
@@ -0,0 +1,49 @@
import { DEFAULT_SPOOL_TOTAL_WEIGHT_G } from '../constants';
/**
* Calcula a porcentagem de filamento disponível.
* Usa 1000g como base (peso total do rolo cheio) por convenção.
*/
export function calcFilamentPercentage(netWeightG: number): number {
const pct = Math.round((netWeightG / DEFAULT_SPOOL_TOTAL_WEIGHT_G) * 100);
return Math.min(100, Math.max(0, pct));
}
/**
* Formata gramas para exibição amigável (ex: 1200g → "1.2 kg", 750g → "750g").
*/
export function formatWeight(grams: number): string {
if (grams >= 1000) {
const kg = (grams / 1000).toFixed(1);
return `${kg} kg`;
}
return `${grams}g`;
}
/**
* Gera um slug legível a partir de modelo, marca e cor (usado em URLs e QR).
* Ex: "PLA Rapid", "Elegoo", "Blue" → "pla-rapid-elegoo-blue"
*/
export function generateSlug(...parts: string[]): string {
return parts
.join('-')
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Garante que um valor hex tenha o prefixo '#'.
*/
export function normalizeHex(hex: string): string {
return hex.startsWith('#') ? hex : `#${hex}`;
}
/**
* Verifica se uma string é um hex de cor válido.
*/
export function isValidHex(hex: string): boolean {
return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex);
}
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import type { User, AuthSession } from '@domain/User';
import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN } from '@shared/constants';
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
setSession: (session: AuthSession) => Promise<void>;
clearSession: () => Promise<void>;
loadStoredSession: () => Promise<void>;
}
/**
* Store de autenticação.
*
* Persiste os tokens no SecureStore do dispositivo.
* O estado `isAuthenticated` é a fonte de verdade para a navegação.
*/
export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true,
setSession: async (session: AuthSession) => {
await SecureStore.setItemAsync(SECURE_STORE_ACCESS_TOKEN, session.accessToken);
await SecureStore.setItemAsync(SECURE_STORE_REFRESH_TOKEN, session.refreshToken);
set({
user: session.user,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
isAuthenticated: true,
isLoading: false,
});
},
clearSession: async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
});
},
loadStoredSession: async () => {
set({ isLoading: true });
try {
const accessToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
if (accessToken) {
// TODO: validar token com /api/v1/users/me e popular o user
set({ accessToken, isAuthenticated: true, isLoading: false });
} else {
set({ isLoading: false });
}
} catch {
set({ isLoading: false });
}
},
}));
+49
View File
@@ -0,0 +1,49 @@
import { create } from 'zustand';
import type { Filament, FilamentFilter } from '@domain/Filament';
interface FilamentState {
filaments: Filament[];
isLoading: boolean;
error: string | null;
activeFilter: FilamentFilter;
setFilaments: (filaments: Filament[]) => void;
addFilament: (filament: Filament) => void;
updateFilament: (filament: Filament) => void;
removeFilament: (id: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setFilter: (filter: FilamentFilter) => void;
resetFilter: () => void;
}
const DEFAULT_FILTER: FilamentFilter = {
sortBy: 'created_at_desc',
page: 1,
perPage: 50,
};
/**
* Store de filamentos.
* Mantém a lista em memória para acesso rápido nas telas de listagem.
*/
export const useFilamentStore = create<FilamentState>((set) => ({
filaments: [],
isLoading: false,
error: null,
activeFilter: DEFAULT_FILTER,
setFilaments: (filaments) => set({ filaments }),
addFilament: (filament) =>
set((state) => ({ filaments: [filament, ...state.filaments] })),
updateFilament: (filament) =>
set((state) => ({
filaments: state.filaments.map((f) => (f.id === filament.id ? filament : f)),
})),
removeFilament: (id) =>
set((state) => ({ filaments: state.filaments.filter((f) => f.id !== id) })),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
setFilter: (filter) => set({ activeFilter: filter }),
resetFilter: () => set({ activeFilter: DEFAULT_FILTER }),
}));
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand';
import type { SpoolPreset } from '@domain/SpoolPreset';
interface PresetState {
presets: SpoolPreset[];
systemPresets: SpoolPreset[];
userPresets: SpoolPreset[];
isLoading: boolean;
error: string | null;
setPresets: (presets: SpoolPreset[]) => void;
addPreset: (preset: SpoolPreset) => void;
updatePreset: (preset: SpoolPreset) => void;
removePreset: (id: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
/**
* Store de presets de carretéis.
* Mantém sistema e usuário separados para facilitar a renderização de seções distintas.
*/
export const usePresetStore = create<PresetState>((set) => ({
presets: [],
systemPresets: [],
userPresets: [],
isLoading: false,
error: null,
setPresets: (presets) =>
set({
presets,
systemPresets: presets.filter((p) => p.isSystem),
userPresets: presets.filter((p) => !p.isSystem),
}),
addPreset: (preset) =>
set((state) => {
const presets = [...state.presets, preset];
return {
presets,
userPresets: presets.filter((p) => !p.isSystem),
};
}),
updatePreset: (preset) =>
set((state) => {
const presets = state.presets.map((p) => (p.id === preset.id ? preset : p));
return {
presets,
userPresets: presets.filter((p) => !p.isSystem),
};
}),
removePreset: (id) =>
set((state) => {
const presets = state.presets.filter((p) => p.id !== id);
return {
presets,
userPresets: presets.filter((p) => !p.isSystem),
};
}),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
}));
+54
View File
@@ -0,0 +1,54 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-native",
"baseUrl": ".",
"paths": {
"@domain/*": [
"src/domain/*"
],
"@ports/*": [
"src/ports/*"
],
"@application/*": [
"src/application/*"
],
"@adapters/*": [
"src/adapters/*"
],
"@presentation/*": [
"src/presentation/*"
],
"@store/*": [
"src/store/*"
],
"@shared/*": [
"src/shared/*"
]
}
},
"include": [
"src",
"app.json",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"node_modules"
],
"extends": "expo/tsconfig.base"
}