From d7fb768d3b8a152736a25ba17df3df2a31ccf59c Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sat, 14 Mar 2026 10:36:13 -0300 Subject: [PATCH] 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. --- mobile/.env.example | 14 + mobile/.expo/README.md | 8 + mobile/.expo/devices.json | 3 + mobile/.expo/types/router.d.ts | 14 + mobile/.gitignore | 6 + mobile/.mise.toml | 2 + mobile/agent.md | 353 + mobile/app.json | 38 + mobile/app/(app)/(tabs)/_layout.tsx | 109 + mobile/app/(app)/(tabs)/add.tsx | 17 + mobile/app/(app)/(tabs)/config.tsx | 238 + mobile/app/(app)/(tabs)/home.tsx | 244 + mobile/app/(app)/(tabs)/inventory.tsx | 132 + mobile/app/(app)/(tabs)/profile.tsx | 234 + mobile/app/(app)/_layout.tsx | 8 + mobile/app/(app)/config/presets/[id]/edit.tsx | 249 + mobile/app/(app)/config/presets/new.tsx | 212 + mobile/app/(app)/filaments/[id]/label.tsx | 307 + mobile/app/(app)/filaments/[id]/qrcode.tsx | 189 + mobile/app/(app)/inventory/[id].tsx | 351 + mobile/app/(app)/inventory/[id]/edit.tsx | 374 + mobile/app/(app)/inventory/filters.tsx | 278 + mobile/app/(app)/inventory/new.tsx | 312 + mobile/app/(auth)/_layout.tsx | 19 + mobile/app/(auth)/forgot-password.tsx | 123 + mobile/app/(auth)/login.tsx | 213 + mobile/app/(auth)/password-reset-done.tsx | 70 + mobile/app/(auth)/register.tsx | 146 + mobile/app/(auth)/reset-password.tsx | 116 + mobile/app/(auth)/verify-email.tsx | 78 + mobile/app/_layout.tsx | 28 + mobile/assets/adaptive-icon.png | Bin 0 -> 5336 bytes mobile/assets/favicon.png | Bin 0 -> 178 bytes mobile/assets/icon.png | Bin 0 -> 5337 bytes mobile/assets/splash.png | Bin 0 -> 13961 bytes mobile/babel.config.js | 24 + mobile/index.js | 1 + mobile/metro.config.js | 7 + mobile/package-lock.json | 12961 ++++++++++++++++ mobile/package.json | 54 + .../adapters/local/LocalFilamentRepository.ts | 127 + .../local/LocalSpoolPresetRepository.ts | 87 + mobile/src/adapters/local/database.ts | 60 + .../src/adapters/remote/ApiAuthRepository.ts | 65 + .../adapters/remote/ApiFilamentRepository.ts | 93 + .../remote/ApiSpoolPresetRepository.ts | 55 + mobile/src/adapters/remote/httpClient.ts | 90 + mobile/src/application/auth/AuthUseCases.ts | 80 + .../filament/CreateFilamentUseCase.ts | 28 + .../filament/DeleteFilamentUseCase.ts | 16 + .../filament/ListFilamentsUseCase.ts | 13 + .../filament/UpdateFilamentUseCase.ts | 40 + .../src/application/preset/PresetUseCases.ts | 60 + mobile/src/domain/Dashboard.ts | 37 + mobile/src/domain/Filament.ts | 65 + mobile/src/domain/SpoolPreset.ts | 31 + mobile/src/domain/User.ts | 44 + mobile/src/ports/AuthRepository.ts | 16 + mobile/src/ports/FilamentRepository.ts | 13 + mobile/src/ports/SpoolPresetRepository.ts | 12 + .../components/filament/ColorSwatch.tsx | 40 + .../components/filament/FilamentCard.tsx | 88 + .../components/filament/StockBar.tsx | 68 + .../presentation/components/layout/Header.tsx | 75 + .../presentation/components/layout/Screen.tsx | 87 + .../src/presentation/components/ui/Badge.tsx | 41 + .../src/presentation/components/ui/Button.tsx | 106 + .../src/presentation/components/ui/Card.tsx | 28 + .../src/presentation/components/ui/Input.tsx | 125 + mobile/src/shared/constants.ts | 32 + mobile/src/shared/theme.ts | 140 + mobile/src/shared/utils/filament.ts | 49 + mobile/src/store/authStore.ts | 69 + mobile/src/store/filamentStore.ts | 49 + mobile/src/store/presetStore.ts | 66 + mobile/tsconfig.json | 54 + 76 files changed, 19681 insertions(+) create mode 100644 mobile/.env.example create mode 100644 mobile/.expo/README.md create mode 100644 mobile/.expo/devices.json create mode 100644 mobile/.expo/types/router.d.ts create mode 100644 mobile/.gitignore create mode 100644 mobile/.mise.toml create mode 100644 mobile/agent.md create mode 100644 mobile/app.json create mode 100644 mobile/app/(app)/(tabs)/_layout.tsx create mode 100644 mobile/app/(app)/(tabs)/add.tsx create mode 100644 mobile/app/(app)/(tabs)/config.tsx create mode 100644 mobile/app/(app)/(tabs)/home.tsx create mode 100644 mobile/app/(app)/(tabs)/inventory.tsx create mode 100644 mobile/app/(app)/(tabs)/profile.tsx create mode 100644 mobile/app/(app)/_layout.tsx create mode 100644 mobile/app/(app)/config/presets/[id]/edit.tsx create mode 100644 mobile/app/(app)/config/presets/new.tsx create mode 100644 mobile/app/(app)/filaments/[id]/label.tsx create mode 100644 mobile/app/(app)/filaments/[id]/qrcode.tsx create mode 100644 mobile/app/(app)/inventory/[id].tsx create mode 100644 mobile/app/(app)/inventory/[id]/edit.tsx create mode 100644 mobile/app/(app)/inventory/filters.tsx create mode 100644 mobile/app/(app)/inventory/new.tsx create mode 100644 mobile/app/(auth)/_layout.tsx create mode 100644 mobile/app/(auth)/forgot-password.tsx create mode 100644 mobile/app/(auth)/login.tsx create mode 100644 mobile/app/(auth)/password-reset-done.tsx create mode 100644 mobile/app/(auth)/register.tsx create mode 100644 mobile/app/(auth)/reset-password.tsx create mode 100644 mobile/app/(auth)/verify-email.tsx create mode 100644 mobile/app/_layout.tsx create mode 100644 mobile/assets/adaptive-icon.png create mode 100644 mobile/assets/favicon.png create mode 100644 mobile/assets/icon.png create mode 100644 mobile/assets/splash.png create mode 100644 mobile/babel.config.js create mode 100644 mobile/index.js create mode 100644 mobile/metro.config.js create mode 100644 mobile/package-lock.json create mode 100644 mobile/package.json create mode 100644 mobile/src/adapters/local/LocalFilamentRepository.ts create mode 100644 mobile/src/adapters/local/LocalSpoolPresetRepository.ts create mode 100644 mobile/src/adapters/local/database.ts create mode 100644 mobile/src/adapters/remote/ApiAuthRepository.ts create mode 100644 mobile/src/adapters/remote/ApiFilamentRepository.ts create mode 100644 mobile/src/adapters/remote/ApiSpoolPresetRepository.ts create mode 100644 mobile/src/adapters/remote/httpClient.ts create mode 100644 mobile/src/application/auth/AuthUseCases.ts create mode 100644 mobile/src/application/filament/CreateFilamentUseCase.ts create mode 100644 mobile/src/application/filament/DeleteFilamentUseCase.ts create mode 100644 mobile/src/application/filament/ListFilamentsUseCase.ts create mode 100644 mobile/src/application/filament/UpdateFilamentUseCase.ts create mode 100644 mobile/src/application/preset/PresetUseCases.ts create mode 100644 mobile/src/domain/Dashboard.ts create mode 100644 mobile/src/domain/Filament.ts create mode 100644 mobile/src/domain/SpoolPreset.ts create mode 100644 mobile/src/domain/User.ts create mode 100644 mobile/src/ports/AuthRepository.ts create mode 100644 mobile/src/ports/FilamentRepository.ts create mode 100644 mobile/src/ports/SpoolPresetRepository.ts create mode 100644 mobile/src/presentation/components/filament/ColorSwatch.tsx create mode 100644 mobile/src/presentation/components/filament/FilamentCard.tsx create mode 100644 mobile/src/presentation/components/filament/StockBar.tsx create mode 100644 mobile/src/presentation/components/layout/Header.tsx create mode 100644 mobile/src/presentation/components/layout/Screen.tsx create mode 100644 mobile/src/presentation/components/ui/Badge.tsx create mode 100644 mobile/src/presentation/components/ui/Button.tsx create mode 100644 mobile/src/presentation/components/ui/Card.tsx create mode 100644 mobile/src/presentation/components/ui/Input.tsx create mode 100644 mobile/src/shared/constants.ts create mode 100644 mobile/src/shared/theme.ts create mode 100644 mobile/src/shared/utils/filament.ts create mode 100644 mobile/src/store/authStore.ts create mode 100644 mobile/src/store/filamentStore.ts create mode 100644 mobile/src/store/presetStore.ts create mode 100644 mobile/tsconfig.json diff --git a/mobile/.env.example b/mobile/.env.example new file mode 100644 index 0000000..8d7bc94 --- /dev/null +++ b/mobile/.env.example @@ -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 diff --git a/mobile/.expo/README.md b/mobile/.expo/README.md new file mode 100644 index 0000000..f7eb5fe --- /dev/null +++ b/mobile/.expo/README.md @@ -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. diff --git a/mobile/.expo/devices.json b/mobile/.expo/devices.json new file mode 100644 index 0000000..5efff6c --- /dev/null +++ b/mobile/.expo/devices.json @@ -0,0 +1,3 @@ +{ + "devices": [] +} diff --git a/mobile/.expo/types/router.d.ts b/mobile/.expo/types/router.d.ts new file mode 100644 index 0000000..e652778 --- /dev/null +++ b/mobile/.expo/types/router.d.ts @@ -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 { + 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}/edit${`?${string}` | `#${string}` | ''}` | `/config/presets/${Router.SingleRoutePart}/edit${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/filaments/${Router.SingleRoutePart}/label${`?${string}` | `#${string}` | ''}` | `/filaments/${Router.SingleRoutePart}/label${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/filaments/${Router.SingleRoutePart}/qrcode${`?${string}` | `#${string}` | ''}` | `/filaments/${Router.SingleRoutePart}/qrcode${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/inventory/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/inventory/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(app)'}/inventory/${Router.SingleRoutePart}/edit${`?${string}` | `#${string}` | ''}` | `/inventory/${Router.SingleRoutePart}/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; } }; + } + } +} diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..5873d9a --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/mobile/.mise.toml b/mobile/.mise.toml new file mode 100644 index 0000000..6a0493c --- /dev/null +++ b/mobile/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "22" diff --git a/mobile/agent.md b/mobile/agent.md new file mode 100644 index 0000000..450ed70 --- /dev/null +++ b/mobile/agent.md @@ -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/` → `/(app)/inventory/` +- QR público: `meowspool.app/f/` (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 +> ``` diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..141bdd6 --- /dev/null +++ b/mobile/app.json @@ -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 + } + } +} diff --git a/mobile/app/(app)/(tabs)/_layout.tsx b/mobile/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..d9f7b4e --- /dev/null +++ b/mobile/app/(app)/(tabs)/_layout.tsx @@ -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 ; + } + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + + + ), + tabBarButton: (props) => ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} + +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', + }, +}); diff --git a/mobile/app/(app)/(tabs)/add.tsx b/mobile/app/(app)/(tabs)/add.tsx new file mode 100644 index 0000000..49d8356 --- /dev/null +++ b/mobile/app/(app)/(tabs)/add.tsx @@ -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 ; +} diff --git a/mobile/app/(app)/(tabs)/config.tsx b/mobile/app/(app)/(tabs)/config.tsx new file mode 100644 index 0000000..6b4faf9 --- /dev/null +++ b/mobile/app/(app)/(tabs)/config.tsx @@ -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 ( + + {/* Header */} + + + Configurações + Presets de Carretéis + + router.push('/(app)/config/presets/new' as never)} + > + + + + + + {/* Presets do Sistema */} + + + PRESETS DO SISTEMA + + Somente leitura + + + {systemPresets.map((preset) => ( + + + + + + {preset.name} + + {/* type field not in SpoolPreset domain yet — show dash */} + Carretel · {preset.spoolWeightG}g + + + + {preset.spoolWeightG}g + + + ))} + + + {/* Meus Presets */} + {userPresets.length > 0 && ( + + MEUS PRESETS + {userPresets.map((preset) => ( + + + + + + {preset.name} + Customizado · {preset.spoolWeightG}g + + + + {preset.spoolWeightG}g + + {isUserOwnedPreset(preset) && ( + <> + router.push(`/(app)/config/presets/${preset.id}/edit` as never)} + > + + + handleDelete(preset.id, preset.name)}> + + + + )} + + + ))} + + )} + + {userPresets.length === 0 && ( + + Nenhum preset personalizado ainda. + router.push('/(app)/config/presets/new' as never)}> + + Criar preset + + + )} + + + ); +} + +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, + }, +}); diff --git a/mobile/app/(app)/(tabs)/home.tsx b/mobile/app/(app)/(tabs)/home.tsx new file mode 100644 index 0000000..61259e1 --- /dev/null +++ b/mobile/app/(app)/(tabs)/home.tsx @@ -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>((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 ( + + + + {/* Cabeçalho */} + + + Bem-vindo de volta, + Seu Inventário + + router.push('/(app)/qrcode/scan')} + > + + + + + {/* Totais */} + + + Total em Estoque + {totalKg} kg + + + Rolos Acabando + {lowStockFilaments.length} unidades + + + + {/* Uso Recente */} + {recentFilaments.length > 0 && ( + <> + USO RECENTE + + {recentFilaments.map((f) => { + const pct = calcFilamentPercentage(f); + return ( + router.push(`/(app)/inventory/${f.id}`)} + activeOpacity={0.8} + > + + {f.model ?? f.material} + {f.brand} · {f.colorHex} + + {formatWeight(f.netWeightG)} + + + + + ); + })} + + + )} + + {/* Inventário rápido */} + {filaments.length > 0 && ( + <> + + INVENTÁRIO + router.push('/(app)/(tabs)/inventory')}> + Ver todos + + + {filaments.slice(0, 4).map((f) => ( + router.push(`/(app)/inventory/${f.id}`)} + activeOpacity={0.8} + > + + + {f.model ?? f.material} + + {f.tempHotendC ? `${f.tempHotendC}°C` : '—'} / {f.tempBedC ? `${f.tempBedC}°C` : '—'} · Fluxo {f.flowFactorPct ?? '—'} + + + + {formatWeight(f.netWeightG)} + + + Pesar + + + + ))} + + )} + + {/* Por Material */} + {Object.keys(byMaterial).length > 0 && ( + <> + POR MATERIAL + {Object.entries(byMaterial).map(([mat, data]) => ( + + + {mat} + {data.count} rolos + {(data.totalG / 1000).toFixed(1)} kg + + ))} + + )} + + {/* Estoque Baixo */} + {lowStockFilaments.length > 0 && ( + <> + ESTOQUE BAIXO + {lowStockFilaments.map((f) => { + const pct = calcFilamentPercentage(f); + return ( + router.push(`/(app)/inventory/${f.id}`)} + activeOpacity={0.8} + > + + + + {f.model ?? f.material} + {f.brand} · {f.material} + + + + {formatWeight(f.netWeightG)} + + + + + ); + })} + + )} + + {/* Estado vazio */} + {!isLoading && filaments.length === 0 && ( + + + Nenhum filamento ainda + Toque em + para adicionar seu primeiro filamento. + + )} + + + ); +} + +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' }, +}); diff --git a/mobile/app/(app)/(tabs)/inventory.tsx b/mobile/app/(app)/(tabs)/inventory.tsx new file mode 100644 index 0000000..811d236 --- /dev/null +++ b/mobile/app/(app)/(tabs)/inventory.tsx @@ -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('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 ; + } + + return ( + + {/* Cabeçalho */} + + + Seu estoque + Inventário + + router.push('/(app)/inventory/filters')} + > + + + + + {/* Busca */} + + + + + + {/* Chips de material */} + + {TABS.map((tab) => ( + setActiveTab(tab)} + style={[styles.chip, activeTab === tab && styles.chipActive]} + > + + {tab} + + + ))} + + + {/* Contagem */} + + {filtered.length} FILAMENTOS + + + Ordenar + + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.list} + ItemSeparatorComponent={() => } + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + Nenhum filamento encontrado. + + } + /> + + ); +} + +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 }, +}); diff --git a/mobile/app/(app)/(tabs)/profile.tsx b/mobile/app/(app)/(tabs)/profile.tsx new file mode 100644 index 0000000..7f543b0 --- /dev/null +++ b/mobile/app/(app)/(tabs)/profile.tsx @@ -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 ( + + {/* Header */} + + Perfil + + + + {/* Avatar */} + + + + + {displayName} + {displayEmail} + {isGoogleLinked && ( + + + Conectado com Google + + )} + + + {/* Conta */} + CONTA + + + + + E-mail + {displayEmail} + + + + + + + + Senha + •••••••• + + + + + + {/* Vinculações */} + VINCULAÇÕES + + + + + Google + {displayEmail} + + + + {isGoogleLinked ? 'Vinculado' : 'Vincular'} + + + + + + {/* Logout */} + + + Sair da Conta + + + + ); +} + +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, + }, +}); diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx new file mode 100644 index 0000000..8a4878f --- /dev/null +++ b/mobile/app/(app)/_layout.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Stack } from 'expo-router'; + +export default function AppLayout(): React.ReactElement { + return ( + + ); +} diff --git a/mobile/app/(app)/config/presets/[id]/edit.tsx b/mobile/app/(app)/config/presets/[id]/edit.tsx new file mode 100644 index 0000000..e0d76b7 --- /dev/null +++ b/mobile/app/(app)/config/presets/[id]/edit.tsx @@ -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; + +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('Plástico'); + const [isLoading, setIsLoading] = useState(false); + + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: preset?.name ?? '', + spoolWeightG: preset?.spoolWeightG ?? undefined, + }, + }); + + if (!preset) { + return ( + + + Preset não encontrado + router.back()}> + Voltar + + + + ); + } + + 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 { + 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 ( + + {/* Header */} + + router.back()}> + + + Editar Preset + + + + + + + {/* Ícone */} + + + + + + + {/* Nome */} + ( + } + /> + )} + /> + + {/* Peso */} + ( + } + rightLabel="g" + /> + )} + /> + + Pese o carretel vazio em uma balança e insira o valor em gramas. + + + {/* Tipo */} + + TIPO DE CARRETEL + Opcional + + + {(['Plástico', 'Papelão', 'Outro'] as SpoolType[]).map((t) => ( + setSpoolType(t)} + style={[styles.chip, spoolType === t && styles.chipActive]} + > + {t} + + ))} + + + + {/* CTA */} + +