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