feat: enhance filament management and UI improvements

- Update SpoolPresetRepository to include user_id in the update query.
- Expand .gitignore to include various environment and temporary files.
- Revise agent documentation for better clarity and formatting.
- Implement pull-to-refresh functionality in the inventory list.
- Integrate API calls for deleting and updating filaments, ensuring state synchronization.
- Add custom color picker for filament color selection with hex validation.
- Update AndroidManifest and Gradle files for improved configuration and permissions.
- Refactor MainActivity and MainApplication for better splash screen handling.
- Update styles and colors for a cohesive UI experience.
- Replace splash screen logos and icons with new assets.
This commit is contained in:
2026-03-14 14:14:06 -03:00
parent 416e13893c
commit 9c0b3d584c
33 changed files with 519 additions and 164 deletions
+45 -9
View File
@@ -9,7 +9,7 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
## Stack ## Stack
| Componente | Biblioteca | Versão mínima | | Componente | Biblioteca | Versão mínima |
|-------------------|-----------------------------------------------|---------------| | ---------------- | ------------------------------------- | ------------- |
| HTTP Framework | `axum` | 0.7 | | HTTP Framework | `axum` | 0.7 |
| Async Runtime | `tokio` (full) | 1.x | | Async Runtime | `tokio` (full) | 1.x |
| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 | | ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 |
@@ -110,7 +110,7 @@ Todas as rotas são prefixadas com `/api/v1`.
### Auth — `/api/v1/auth` ### Auth — `/api/v1/auth`
| Método | Rota | Handler | Acesso | | Método | Rota | Handler | Acesso |
|--------|-----------------------|---------------------------|--------------------------------| | ------ | ------------------ | ------------------------- | ------------------------------- |
| POST | `/register` | `register_handler` | Público | | POST | `/register` | `register_handler` | Público |
| POST | `/login` | `login_handler` | Público | | POST | `/login` | `login_handler` | Público |
| POST | `/oauth/google` | `google_oauth_handler` | Público | | POST | `/oauth/google` | `google_oauth_handler` | Público |
@@ -118,19 +118,19 @@ Todas as rotas são prefixadas com `/api/v1`.
| POST | `/logout` | `logout_handler` | Autenticado | | POST | `/logout` | `logout_handler` | Autenticado |
| POST | `/forgot-password` | `forgot_password_handler` | Público | | POST | `/forgot-password` | `forgot_password_handler` | Público |
| POST | `/verify-email` | `verify_email_handler` | Público | | POST | `/verify-email` | `verify_email_handler` | Público |
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset)| | POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
### Users — `/api/v1/users` ### Users — `/api/v1/users`
| Método | Rota | Handler | Acesso | | Método | Rota | Handler | Acesso |
|--------|-------|---------------------|-------------| | ------ | ----- | ------------------- | ----------- |
| GET | `/me` | `get_me_handler` | Autenticado | | GET | `/me` | `get_me_handler` | Autenticado |
| PUT | `/me` | `update_me_handler` | Autenticado | | PUT | `/me` | `update_me_handler` | Autenticado |
### Filaments — `/api/v1/filaments` ### Filaments — `/api/v1/filaments`
| Método | Rota | Handler | Acesso | | Método | Rota | Handler | Acesso |
|--------|-------------------|----------------------------|-------------| | ------ | ---------------- | ------------------------- | ----------- |
| GET | `/` | `list_filaments_handler` | Autenticado | | GET | `/` | `list_filaments_handler` | Autenticado |
| POST | `/` | `create_filament_handler` | Autenticado | | POST | `/` | `create_filament_handler` | Autenticado |
| GET | `/:id` | `get_filament_handler` | Autenticado | | GET | `/:id` | `get_filament_handler` | Autenticado |
@@ -140,6 +140,7 @@ Todas as rotas são prefixadas com `/api/v1`.
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado | | GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
**Query params de listagem (`GET /filaments`):** **Query params de listagem (`GET /filaments`):**
- `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...) - `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...)
- `brand` — filtra por marca - `brand` — filtra por marca
- `search` — busca em marca, modelo e notas - `search` — busca em marca, modelo e notas
@@ -150,19 +151,20 @@ Todas as rotas são prefixadas com `/api/v1`.
### Spool Presets — `/api/v1/spool-presets` ### Spool Presets — `/api/v1/spool-presets`
| Método | Rota | Handler | Acesso | | Método | Rota | Handler | Acesso |
|--------|----------|--------------------------|-------------------------------------| | ------ | ------ | ----------------------- | ------------------------------------ |
| GET | `/` | `list_presets_handler` | Autenticado | | GET | `/` | `list_presets_handler` | Autenticado |
| POST | `/` | `create_preset_handler` | Autenticado | | POST | `/` | `create_preset_handler` | Autenticado |
| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user)| | PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user) |
| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user)| | DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user) |
### Dashboard — `/api/v1/dashboard` ### Dashboard — `/api/v1/dashboard`
| Método | Rota | Handler | Acesso | | Método | Rota | Handler | Acesso |
|--------|------|----------------------|-------------| | ------ | ---- | ------------------- | ----------- |
| GET | `/` | `dashboard_handler` | Autenticado | | GET | `/` | `dashboard_handler` | Autenticado |
**Resposta do dashboard:** **Resposta do dashboard:**
```json ```json
{ {
"total_stock_kg": 14.2, "total_stock_kg": 14.2,
@@ -361,11 +363,13 @@ O backend implementa **last-write-wins com timestamp**:
## Geração de QR Code e Etiqueta SVG ## Geração de QR Code e Etiqueta SVG
### QR Code (`GET /filaments/:id/qrcode`) ### QR Code (`GET /filaments/:id/qrcode`)
- Gera QR Code com deep link: `meowspool://filament/:id` (singular) - Gera QR Code com deep link: `meowspool://filament/:id` (singular)
- Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg` - Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg`
- Biblioteca: crate `qrcode` - Biblioteca: crate `qrcode`
### Etiqueta SVG (`GET /filaments/:id/label.svg`) ### Etiqueta SVG (`GET /filaments/:id/label.svg`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30) - Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30)
- Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido - Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido
- `Content-Type: image/svg+xml` - `Content-Type: image/svg+xml`
@@ -381,3 +385,35 @@ O backend implementa **last-write-wins com timestamp**:
- Queries usam **bind parameters** do SQLx — nunca interpolação de string em SQL. - Queries usam **bind parameters** do SQLx — nunca interpolação de string em SQL.
- `user_id` é sempre extraído do token JWT, nunca aceito como parâmetro de URL ou body. - `user_id` é sempre extraído do token JWT, nunca aceito como parâmetro de URL ou body.
- Presets do sistema (`is_system = true`) são protegidos no nível de serviço: edição ou deleção retorna `403 Forbidden`. - Presets do sistema (`is_system = true`) são protegidos no nível de serviço: edição ou deleção retorna `403 Forbidden`.
---
## Mudanças Recentes (14/03/2026)
### ✅ Correção: PUT Spool Preset retornava FORBIDDEN
**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()`
**Problema:**
O SQL UPDATE não validava o `user_id` na cláusula WHERE. Qualquer usuário poderia tentar editar presets de outros usuários ou presets do sistema.
```rust
// ❌ ANTES (inseguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false
RETURNING ...
```
**Solução:**
Adicionado `AND user_id = $4` para validar propriedade antes de atualizar:
```rust
// ✅ DEPOIS (seguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false AND user_id = $4
RETURNING ...
```
Agora o repositório valida que o preset pertence ao usuário autenticado (extraído do JWT). Tentativas de editar presets de outro usuário ou do sistema recebem `404 Not Found` (sem vazar que o preset existe).
@@ -5,6 +5,6 @@ variables:
- secret: true - secret: true
name: refresh_token name: refresh_token
- name: preset_id - name: preset_id
value: 38e439ba-0289-4bf0-9f4b-f9cb12991167 value: 88eab1b9-e979-46db-940f-59cac97df60b
- name: filament_id - name: filament_id
value: 0b371f84-706a-4eaa-bff0-33556fa833b4 value: da5e431c-bc2e-4075-aa1c-ce82ee9567b4
@@ -21,7 +21,7 @@ CREATE INDEX idx_spool_presets_is_system ON spool_presets (is_system);
-- Presets built-in do sistema (somente leitura para usuários) -- Presets built-in do sistema (somente leitura para usuários)
INSERT INTO spool_presets (name, spool_weight_g, is_system) VALUES INSERT INTO spool_presets (name, spool_weight_g, is_system) VALUES
('Bambu Lab (Plástico)', 250, TRUE), ('Bambu Lab (Plástico)', 250, TRUE),
('Elegoo (Papelão)', 200, TRUE), ('Elegoo (Papelão)', 156, TRUE),
('Creality (Plástico)', 230, TRUE), ('Creality (Plástico)', 230, TRUE),
('Prusament (Plástico)', 201, TRUE), ('Prusament (Plástico)', 201, TRUE),
('Sunlu (Papelão)', 200, TRUE), ('Sunlu (Papelão)', 200, TRUE),
@@ -69,12 +69,13 @@ impl SpoolPresetRepository for PostgresSpoolPresetRepository {
let row = sqlx::query_as::<_, SpoolPresetRow>( let row = sqlx::query_as::<_, SpoolPresetRow>(
r#"UPDATE spool_presets r#"UPDATE spool_presets
SET name = $2, spool_weight_g = $3 SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false WHERE id = $1 AND is_system = false AND user_id = $4
RETURNING id, name, spool_weight_g, is_system, user_id, created_at"#, RETURNING id, name, spool_weight_g, is_system, user_id, created_at"#,
) )
.bind(preset.id) .bind(preset.id)
.bind(&preset.name) .bind(&preset.name)
.bind(preset.spool_weight_g) .bind(preset.spool_weight_g)
.bind(preset.user_id)
.fetch_one(self.db.as_ref()) .fetch_one(self.db.as_ref())
.await?; .await?;
+50
View File
@@ -4,3 +4,53 @@
expo-env.d.ts expo-env.d.ts
# @end expo-cli # @end expo-cli
# Environment Variables
.env
.env.local
.env.*.local
# Node
node_modules/
npm-debug.*
yarn-debug.*
yarn-error.*
lerna-debug.log
package-lock.json
yarn.lock
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
*.sublime-workspace
*.sublime-project
# React Native / Expo
android/
ios/
*.jks
*.keystore
build/
.gradle/
# Expo
.expo/
dist/
.expo-shared/
# Logs
logs/
*.log
# Temporary files
*.tmp
.cache/
tmp/
# OS
Thumbs.db
.env.*.local
+158 -11
View File
@@ -11,13 +11,13 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
## Stack ## Stack
| Componente | Biblioteca | Observação | | Componente | Biblioteca | Observação |
|-----------------------|------------------------------------|-----------------------------------------------| | ----------------- | ----------------------------------- | --------------------------------------------------------- |
| Framework | `expo` ~51 | Managed Workflow | | Framework | `expo` ~51 | Managed Workflow |
| Navigation | `expo-router` ~3 | File-system routing | | Navigation | `expo-router` ~3 | File-system routing |
| Linguagem | TypeScript 5.x | strict mode | | Linguagem | TypeScript 5.x | strict mode |
| Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) | | Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) |
| Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) | | Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) |
| UI | React Native + `@expo/vector-icons`| Ionicons | | UI | React Native + `@expo/vector-icons` | Ionicons |
| Forms | `react-hook-form` + `zod` | Validação em runtime | | Forms | `react-hook-form` + `zod` | Validação em runtime |
| Estado global | `zustand` | Stores em `src/store/` | | Estado global | `zustand` | Stores em `src/store/` |
| HTTP | `axios` | Interceptor Bearer + refresh automático | | HTTP | `axios` | Interceptor Bearer + refresh automático |
@@ -171,7 +171,7 @@ Arquivo: `src/shared/theme.ts`
### Paleta (siamês) ### Paleta (siamês)
| Token | Valor | Uso | | Token | Valor | Uso |
|------------------|-------------|--------------------------------------| | --------------- | ----------- | --------------------------------- |
| `bgBase` | `#1E1B18` | Fundo geral | | `bgBase` | `#1E1B18` | Fundo geral |
| `bgSurface` | `#2A2622` | Cards, containers | | `bgSurface` | `#2A2622` | Cards, containers |
| `bgHover` | `#332F2B` | Hover/pressed em cards | | `bgHover` | `#332F2B` | Hover/pressed em cards |
@@ -255,10 +255,10 @@ operation TEXT, payload TEXT, created_at TEXT
Sempre usar aliases em vez de caminhos relativos: Sempre usar aliases em vez de caminhos relativos:
```ts ```ts
import { Filament } from '@domain/Filament'; import { Filament } from "@domain/Filament";
import { useFilamentStore } from '@store/filamentStore'; import { useFilamentStore } from "@store/filamentStore";
import { colors } from '@shared/theme'; import { colors } from "@shared/theme";
import { Button } from '@presentation/components/ui/Button'; import { Button } from "@presentation/components/ui/Button";
``` ```
### Componentes de tela ### Componentes de tela
@@ -284,7 +284,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
### Auth ### Auth
| Método | Rota | Descrição | | Método | Rota | Descrição |
|--------|-------------------------------|------------------------------| | ------ | ----------------------- | --------------------------- |
| POST | `/auth/login` | Login email/senha | | POST | `/auth/login` | Login email/senha |
| POST | `/auth/register` | Cadastro | | POST | `/auth/register` | Cadastro |
| POST | `/auth/google` | OAuth Google | | POST | `/auth/google` | OAuth Google |
@@ -297,7 +297,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
### Filaments ### Filaments
| Método | Rota | Descrição | | Método | Rota | Descrição |
|--------|-------------------------------|------------------------------| | ------ | ----------------------- | -------------------- |
| GET | `/filaments` | Listar (com filtros) | | GET | `/filaments` | Listar (com filtros) |
| POST | `/filaments` | Criar | | POST | `/filaments` | Criar |
| GET | `/filaments/:id` | Detalhe | | GET | `/filaments/:id` | Detalhe |
@@ -309,7 +309,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
### Spool Presets ### Spool Presets
| Método | Rota | Descrição | | Método | Rota | Descrição |
|--------|-------------------------------|------------------------------| | ------ | -------------------- | --------------------------- |
| GET | `/spool-presets` | Listar (sistema + usuário) | | GET | `/spool-presets` | Listar (sistema + usuário) |
| POST | `/spool-presets` | Criar preset do usuário | | POST | `/spool-presets` | Criar preset do usuário |
| PATCH | `/spool-presets/:id` | Atualizar preset do usuário | | PATCH | `/spool-presets/:id` | Atualizar preset do usuário |
@@ -318,7 +318,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
### Dashboard ### Dashboard
| Método | Rota | Descrição | | Método | Rota | Descrição |
|--------|-------------------------------|------------------------------| | ------ | ------------ | ---------------------------- |
| GET | `/dashboard` | Dados agregados do dashboard | | GET | `/dashboard` | Dados agregados do dashboard |
--- ---
@@ -336,6 +336,152 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
--- ---
## Mudanças Recentes (14/03/2026)
### ✅ Implementações Completadas
#### 1. **Delete Filament — Integração com API**
- **Arquivos**: `app/(app)/inventory/[id].tsx`, `app/(app)/inventory/[id]/edit.tsx`
- **Mudança**: `handleDelete()` agora chama `deleteFilamentUseCase.execute()` antes de remover do store local
- **Código**:
```typescript
const handleDelete = async () => {
try {
const user = useAuthStore.getState().user;
await deleteFilamentUseCase.execute(filament!.id, user?.id);
removeFilament(filament!.id);
Alert.alert("Sucesso", "Filamento deletado da API");
} catch (error) {
Alert.alert("Erro", "Falha ao deletar: " + (error as Error).message);
}
};
```
- **Impacto**: Delete agora funciona corretamente em ambos os screens (detail e edit)
#### 2. **Pull-to-Refresh — Inventory List**
- **Arquivo**: `app/(tabs)/inventory.tsx`
- **Mudança**: Adicionado `RefreshControl` ao FlatList com chamada a `listFilamentsUseCase.execute()`
- **Código**:
```typescript
const [isRefreshing, setIsRefreshing] = useState(false);
const onRefresh = async () => {
setIsRefreshing(true);
try {
const user = useAuthStore.getState().user;
const result = await listFilamentsUseCase.execute(user?.id);
setFilaments(result);
} finally {
setIsRefreshing(false);
}
};
<FlatList
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
// ... resto do componente
/>
```
- **Impacto**: Usuários podem puxar para baixo e sincronizar lista sem reabrir o app
#### 3. **Update Filament — Integração com API**
- **Arquivo**: `app/(app)/inventory/[id]/edit.tsx`
- **Mudança**: `onSubmit()` agora chama `updateFilamentUseCase.execute()` em vez de apenas atualizar o store local
- **Código**:
```typescript
const onSubmit = async () => {
try {
const user = useAuthStore.getState().user;
const result = await updateFilamentUseCase.execute(
{
id: filament!.id,
brand: formData.brand,
model: formData.model,
color: formData.color,
weight_g: parseFloat(formData.weight_g),
},
user?.id,
);
updateFilament(result);
Alert.alert("Sucesso", "Filamento atualizado na API");
router.back();
} catch (error) {
Alert.alert("Erro", "Falha ao atualizar: " + (error as Error).message);
}
};
```
- **Impacto**: Edições de filamento agora persistem no servidor
#### 4. **Custom Color Picker — Hex Input com Validação**
- **Arquivos**: `app/(app)/inventory/new.tsx`, `app/(app)/inventory/[id]/edit.tsx`
- **Mudança**: Adicionado `handleOpenColorPicker()` que usa `Alert.prompt()` para capturar hexadecimais
- **Validação**:
```typescript
const isValidHex = (hex: string): boolean => {
return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex);
};
const normalizeHex = (hex: string): string => {
return hex.startsWith("#") ? hex : `#${hex}`;
};
const handleOpenColorPicker = () => {
setIsOpeningColorPicker(true);
Alert.prompt(
"Hex Color",
"Enter hex color (e.g., #FF5733 or FF5733)",
[
{
text: "Cancel",
onPress: () => setIsOpeningColorPicker(false),
style: "cancel",
},
{
text: "Add Color",
onPress: (input: string | undefined) => {
if (input && isValidHex(input)) {
setFormData((prev) => ({ ...prev, color: normalizeHex(input) }));
} else {
Alert.alert("Invalid hex color", "Please enter a valid hex code");
}
setIsOpeningColorPicker(false);
},
},
],
"plain-text",
);
};
```
- **Integração**: Botão "+" vinculado a `handleOpenColorPicker`
- **Impacto**: Usuários podem agora inserir cores customizadas por código hexadecimal
#### 5. **Sincronização de Estado — API-First Pattern**
- **Padrão**: Todas operações agora seguem: **API call → store update**
- **Benefício**: Source of truth centralizada no servidor
- **Implementação**:
- Delete: `deleteFilamentUseCase` → `removeFilament()`
- Update: `updateFilamentUseCase` → `updateFilament(result)`
- Refresh: `listFilamentsUseCase` → `setFilaments(result)`
- Create: `createFilamentUseCase` → `addFilament(result)`
### Arquitetura Mantida
- Todos use cases injetados via `container.ts` (Dependency Injection)
- Zod schemas usados para validação de formulários
- JWT interceptor em Axios automaticamente adiciona `Authorization` header
- Error handling com `Alert.alert()` visível ao usuário
---
## Executando o Projeto ## Executando o Projeto
```bash ```bash
@@ -354,6 +500,7 @@ mise exec -- npx expo run:ios
``` ```
> **Variável de ambiente**: crie `mobile/.env` com: > **Variável de ambiente**: crie `mobile/.env` com:
>
> ``` > ```
> EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1 > EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1
> ``` > ```
+3 -3
View File
@@ -87,13 +87,13 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
namespace "com.meowspool" namespace 'com.meowspool.app'
defaultConfig { defaultConfig {
applicationId "com.meowspool" applicationId 'com.meowspool.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
+17 -13
View File
@@ -1,29 +1,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- OPTIONAL PERMISSIONS, REMOVE WHATEVER YOU DO NOT NEED --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<!-- These require runtime permissions on M -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- END OPTIONAL PERMISSIONS -->
<queries> <queries>
<!-- Support checking for http(s) links via the Linking API -->
<intent> <intent>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" /> <data android:scheme="https"/>
</intent> </intent>
</queries> </queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name="com.meowspool.app.MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true"> <meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<activity android:name="com.meowspool.app.MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true"> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="meowspool"/>
</intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>
@@ -1,4 +1,5 @@
package com.meowspool.app package com.meowspool.app
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -10,15 +11,15 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper import expo.modules.ReactActivityDelegateWrapper
import com.meowspool.R
import com.meowspool.BuildConfig
class MainActivity : ReactActivity() { class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support // Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar. // coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen. // This is required for expo-splash-screen.
setTheme(R.style.AppTheme); // setTheme(R.style.AppTheme);
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
SplashScreenManager.registerOnActivity(this)
// @generated end expo-splashscreen
super.onCreate(null) super.onCreate(null)
} }
@@ -16,8 +16,6 @@ import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper import expo.modules.ReactNativeHostWrapper
import com.meowspool.BuildConfig
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 0 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<resources> <resources>
<color name="splashscreen_background">#FFFFFF</color> <color name="splashscreen_background">#1E1B18</color>
<color name="iconBackground">#1E1B18</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#1E1B18</color>
</resources> </resources>
@@ -1,3 +1,6 @@
<resources> <resources>
<string name="app_name">MeowSpool</string> <string name="app_name">MeowSpool</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
</resources> </resources>
@@ -1,8 +1,14 @@
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#1E1B18</item>
</style> </style>
<style name="Theme.App.SplashScreen" parent="AppTheme"> <style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="android:windowBackground">@drawable/splashscreen_logo</item> <item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
</style> </style>
</resources> </resources>
+5 -2
View File
@@ -10,13 +10,12 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=false
# AndroidX package structure to make it clearer which packages are bundled with the # AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
@@ -60,3 +59,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK. # Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
+21 -2
View File
@@ -1,11 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, View, Text, FlatList, TouchableOpacity, StyleSheet, TextInput, RefreshControl,
} from 'react-native'; } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useFilamentStore } from '@store/filamentStore'; import { useFilamentStore } from '@store/filamentStore';
import { listFilamentsUseCase } from '@infrastructure/container';
import { useAuthStore } from '@store/authStore';
import { FilamentCard } from '@presentation/components/filament/FilamentCard'; import { FilamentCard } from '@presentation/components/filament/FilamentCard';
import type { Filament } from '@domain/Filament'; import type { Filament } from '@domain/Filament';
import { colors, typography, spacing, radius } from '@shared/theme'; import { colors, typography, spacing, radius } from '@shared/theme';
@@ -19,9 +21,11 @@ const TABS = ['Todos', ...MATERIALS.slice(0, 4)] as const;
*/ */
export default function InventoryScreen(): React.ReactElement { export default function InventoryScreen(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const { filaments } = useFilamentStore(); const { filaments, setFilaments } = useFilamentStore();
const { user } = useAuthStore();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<string>('Todos'); const [activeTab, setActiveTab] = useState<string>('Todos');
const [isRefreshing, setIsRefreshing] = useState(false);
const filtered = filaments.filter((f) => { const filtered = filaments.filter((f) => {
const matchMaterial = activeTab === 'Todos' || f.material === activeTab; const matchMaterial = activeTab === 'Todos' || f.material === activeTab;
@@ -33,6 +37,18 @@ export default function InventoryScreen(): React.ReactElement {
return matchMaterial && matchSearch; return matchMaterial && matchSearch;
}); });
async function onRefresh(): Promise<void> {
setIsRefreshing(true);
try {
const updated = await listFilamentsUseCase.execute(user?.id || '');
setFilaments(updated);
} catch (err) {
console.error('refresh filaments error', err);
} finally {
setIsRefreshing(false);
}
}
function renderItem({ item }: { item: Filament }): React.ReactElement { function renderItem({ item }: { item: Filament }): React.ReactElement {
return <FilamentCard filament={item} />; return <FilamentCard filament={item} />;
} }
@@ -96,6 +112,9 @@ export default function InventoryScreen(): React.ReactElement {
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />} ItemSeparatorComponent={() => <View style={styles.separator} />}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} tintColor={colors.accent} />
}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.empty}> <View style={styles.empty}>
<Ionicons name="layers-outline" size={40} color={colors.textSecondary} /> <Ionicons name="layers-outline" size={40} color={colors.textSecondary} />
+10 -1
View File
@@ -7,6 +7,8 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useFilamentStore } from '@store/filamentStore'; import { useFilamentStore } from '@store/filamentStore';
import { usePresetStore } from '@store/presetStore'; import { usePresetStore } from '@store/presetStore';
import { useAuthStore } from '@store/authStore';
import { deleteFilamentUseCase } from '@infrastructure/container';
import { calcFilamentPercentage } from '@domain/Filament'; import { calcFilamentPercentage } from '@domain/Filament';
import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme'; import { colors, typography, spacing, radius, getStockColor, getStockBgColor } from '@shared/theme';
import { formatWeight } from '@shared/utils/filament'; import { formatWeight } from '@shared/utils/filament';
@@ -52,9 +54,16 @@ export default function FilamentDetailScreen(): React.ReactElement {
{ {
text: 'Excluir', text: 'Excluir',
style: 'destructive', style: 'destructive',
onPress: () => { onPress: async () => {
try {
const { user } = useAuthStore.getState();
await deleteFilamentUseCase.execute(filament!.id, user?.id || '');
removeFilament(filament!.id); removeFilament(filament!.id);
router.back(); router.back();
} catch (err) {
console.error('delete filament error', err);
Alert.alert('Erro', 'Não foi possível deletar o filamento.');
}
}, },
}, },
], ],
+55 -9
View File
@@ -10,13 +10,15 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { usePresetStore } from '@store/presetStore'; import { usePresetStore } from '@store/presetStore';
import { useFilamentStore } from '@store/filamentStore'; import { useFilamentStore } from '@store/filamentStore';
import { useAuthStore } from '@store/authStore';
import { deleteFilamentUseCase, updateFilamentUseCase } from '@infrastructure/container';
import { Input } from '@presentation/components/ui/Input'; import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button'; import { Button } from '@presentation/components/ui/Button';
import { Card } from '@presentation/components/ui/Card'; import { Card } from '@presentation/components/ui/Card';
import { calcNetWeight } from '@domain/Filament'; import { calcNetWeight } from '@domain/Filament';
import { colors, typography, spacing, radius } from '@shared/theme'; import { colors, typography, spacing, radius } from '@shared/theme';
import { MATERIALS, type Material } from '@shared/constants'; import { MATERIALS, type Material } from '@shared/constants';
import { formatWeight } from '@shared/utils/filament'; import { formatWeight, isValidHex, normalizeHex } from '@shared/utils/filament';
const schema = z.object({ const schema = z.object({
brand: z.string().min(1, 'Marca obrigatória'), brand: z.string().min(1, 'Marca obrigatória'),
@@ -48,6 +50,7 @@ export default function EditFilamentScreen(): React.ReactElement {
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA'); const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null); const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({ const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -82,23 +85,66 @@ export default function EditFilamentScreen(): React.ReactElement {
); );
} }
function handleDelete(): void { const handleOpenColorPicker = (): void => {
if (isOpeningColorPicker) return;
setIsOpeningColorPicker(true);
Alert.prompt(
'Cor Customizada',
'Digite um código de cor hexadecimal (ex: #FF5733)',
[
{ text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
{
text: 'Confirmar',
onPress: (value) => {
setIsOpeningColorPicker(false);
if (!value) return;
const normalized = normalizeHex(value.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
},
},
],
'plain-text',
selectedColor,
);
};
const handleDelete = async (): Promise<void> => {
Alert.alert( Alert.alert(
'Excluir filamento', 'Confirmar Deleção',
`Deseja excluir "${filament!.brand}${filament!.model ? ` ${filament!.model}` : ''}"? Esta ação não pode ser desfeita.`, 'Tem certeza que deseja deletar este filamento?',
[ [
{ text: 'Cancelar', style: 'cancel' }, { text: 'Cancelar', style: 'cancel' },
{ {
text: 'Excluir', text: 'Deletar',
style: 'destructive', style: 'destructive',
onPress: () => { onPress: async () => {
const user = useAuthStore.getState().user;
if (!user) {
Alert.alert('Erro', 'Usuário não autenticado.');
return;
}
setIsLoading(true);
try {
await deleteFilamentUseCase.execute(filament!.id, user.id);
removeFilament(filament!.id); removeFilament(filament!.id);
router.replace('/(app)/(tabs)/inventory'); Alert.alert('Sucesso', 'Filamento deletado.');
router.back();
} catch (err) {
console.error('delete filament error', err);
Alert.alert('Erro', 'Falha ao deletar: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
}, },
}, },
], ],
); );
} };
async function onSubmit(data: FormData): Promise<void> { async function onSubmit(data: FormData): Promise<void> {
if (!selectedPresetId) { if (!selectedPresetId) {
@@ -163,7 +209,7 @@ export default function EditFilamentScreen(): React.ReactElement {
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]} style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
/> />
))} ))}
<TouchableOpacity style={styles.colorAddBtn}> <TouchableOpacity style={styles.colorAddBtn} onPress={handleOpenColorPicker}>
<Ionicons name="add" size={18} color={colors.textSecondary} /> <Ionicons name="add" size={18} color={colors.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
+32 -2
View File
@@ -19,6 +19,7 @@ import { colors, typography, spacing, radius } from '@shared/theme';
import { MATERIALS, type Material } from '@shared/constants'; import { MATERIALS, type Material } from '@shared/constants';
import { formatWeight } from '@shared/utils/filament'; import { formatWeight } from '@shared/utils/filament';
import { createFilamentUseCase } from '@infrastructure/container'; import { createFilamentUseCase } from '@infrastructure/container';
import { isValidHex, normalizeHex } from '@shared/utils/filament';
const schema = z.object({ const schema = z.object({
brand: z.string().min(1, 'Marca obrigatória'), brand: z.string().min(1, 'Marca obrigatória'),
@@ -48,6 +49,7 @@ export default function NewFilamentScreen(): React.ReactElement {
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA'); const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null); const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente // Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
useEffect(() => { useEffect(() => {
@@ -67,6 +69,34 @@ export default function NewFilamentScreen(): React.ReactElement {
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG) ? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
: 0; : 0;
const pct = Math.min(100, Math.round((netWeight / 1000) * 100)); const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
function handleOpenColorPicker(): void {
if (isOpeningColorPicker) return;
setIsOpeningColorPicker(true);
Alert.prompt(
'Cor Customizada',
'Digite um código de cor hexadecimal (ex: #FF5733)',
[
{ text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
{
text: 'Confirmar',
onPress: (value) => {
setIsOpeningColorPicker(false);
if (!value) return;
const normalized = normalizeHex(value.trim());
if (isValidHex(normalized)) {
setSelectedColor(normalized);
setHexInput(normalized);
} else {
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
}
},
},
],
'plain-text',
selectedColor,
);
}
async function onSubmit(data: FormData): Promise<void> { async function onSubmit(data: FormData): Promise<void> {
if (!selectedPresetId) { if (!selectedPresetId) {
@@ -102,7 +132,7 @@ export default function NewFilamentScreen(): React.ReactElement {
} }
return ( return (
<SafeAreaView style={styles.safe}> <SafeAreaView style={styles.safe}> onPress={handleOpenColorPicker}
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled"> <ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
@@ -130,7 +160,7 @@ export default function NewFilamentScreen(): React.ReactElement {
style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]} style={[styles.colorDot, { backgroundColor: c }, selectedColor === c && styles.colorDotActive]}
/> />
))} ))}
<TouchableOpacity style={styles.colorAddBtn}> <TouchableOpacity style={styles.colorAddBtn} onPress={handleOpenColorPicker}>
<Ionicons name="add" size={18} color={colors.textSecondary} /> <Ionicons name="add" size={18} color={colors.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>