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

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