feat: adicionar suporte a impressoras pequenas com parâmetro dpi e layout adaptativo para etiquetas
This commit is contained in:
@@ -15,7 +15,8 @@
|
|||||||
"mcp__paper__get_screenshot",
|
"mcp__paper__get_screenshot",
|
||||||
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's|http://0\\\\.0\\\\.0\\\\.0:8080|{{base_url}}|g; s|https://meowspool\\\\.felipecncloud\\\\.com|{{base_url}}|g' {} +)",
|
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's|http://0\\\\.0\\\\.0\\\\.0:8080|{{base_url}}|g; s|https://meowspool\\\\.felipecncloud\\\\.com|{{base_url}}|g' {} +)",
|
||||||
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" ! -name \"Dashboard.yml\" -exec sed -i 's| url: \\\\\\({{.*}}\\\\\\)| url: \"\"\\\\1\"\"|g' {} +)",
|
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" ! -name \"Dashboard.yml\" -exec sed -i 's| url: \\\\\\({{.*}}\\\\\\)| url: \"\"\\\\1\"\"|g' {} +)",
|
||||||
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's| url: \"\"\\\\?\\\\\\(.*\\\\\\)\"\"\\\\?$| url: \"\"\\\\1\"\"|g; s| url: \"\"\\\\\\(.*\\\\\\)\"\"\"\"\\\\?$| url: \"\"\\\\1\"\"|g' {} +)"
|
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's| url: \"\"\\\\?\\\\\\(.*\\\\\\)\"\"\\\\?$| url: \"\"\\\\1\"\"|g; s| url: \"\"\\\\\\(.*\\\\\\)\"\"\"\"\\\\?$| url: \"\"\\\\1\"\"|g' {} +)",
|
||||||
|
"Bash(cargo check:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-5
@@ -374,16 +374,17 @@ O backend implementa **last-write-wins com timestamp**:
|
|||||||
|
|
||||||
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
|
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
|
||||||
|
|
||||||
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
|
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional), `dpi` (opcional, padrão 96)
|
||||||
- Retorna SVG com os campos selecionados embutidos
|
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20`
|
||||||
|
- `dpi` afeta `px_per_mm` e portanto as dimensões em pixels do SVG gerado
|
||||||
- `Content-Type: image/svg+xml`
|
- `Content-Type: image/svg+xml`
|
||||||
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
|
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
|
||||||
|
|
||||||
### Etiqueta PDF (`GET /filaments/:id/label.pdf`)
|
### Etiqueta PDF (`GET /filaments/:id/label.pdf`)
|
||||||
|
|
||||||
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
|
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional), `dpi` (opcional, reservado)
|
||||||
- Retorna PDF de página única com dimensões exatas em mm — ideal para impressoras de etiqueta (Brother, Dymo)
|
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20` — fontes e posições proporcionais, garante que nenhum elemento saia da página
|
||||||
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code embutido como PNG luma
|
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code PNG luma embutido com `interpolate: false` (bordas nítidas em impressoras térmicas)
|
||||||
- `Content-Type: application/pdf`
|
- `Content-Type: application/pdf`
|
||||||
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
|
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
|
||||||
|
|
||||||
@@ -404,6 +405,33 @@ Se `fields` for omitido, todos os elementos são incluídos.
|
|||||||
|
|
||||||
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
|
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
|
||||||
|
|
||||||
|
### Parâmetro `dpi` (SVG e PDF)
|
||||||
|
|
||||||
|
Controla o DPI alvo da impressora. Afeta o cálculo de pixels do SVG e garante QR nítido em impressoras térmicas.
|
||||||
|
|
||||||
|
| Impressora | `dpi` recomendado |
|
||||||
|
| --------------------- | ----------------- |
|
||||||
|
| Tela / padrão | 96 (omitir param) |
|
||||||
|
| Niimbot D11/D110 | 203 |
|
||||||
|
| Brother QL / Dymo | 300 |
|
||||||
|
|
||||||
|
### Layout adaptativo ("mini" vs padrão)
|
||||||
|
|
||||||
|
Etiquetas com `width_mm < 30` **ou** `height_mm < 20` ativam o modo **mini**, que:
|
||||||
|
- Usa fontes menores (proporcionais à altura física)
|
||||||
|
- Posiciona QR Code com margem de 1mm (vs 2mm no padrão)
|
||||||
|
- Estreita a barra de cor (~11% da largura vs ~5% + 6px no padrão)
|
||||||
|
- Encurta o rótulo de peso: `"320g"` em vez de `"320g disponível"`
|
||||||
|
- Separador de marca/material: espaço simples em vez de ` · ` (economiza espaço)
|
||||||
|
- No SVG, o separador de meta usa espaço em vez de ` · `
|
||||||
|
- Garante que nenhum texto extrapole a borda inferior da etiqueta
|
||||||
|
|
||||||
|
Exemplo para Niimbot 22×14mm:
|
||||||
|
```
|
||||||
|
GET /api/v1/filaments/:id/label.pdf?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
|
||||||
|
GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Segurança
|
## Segurança
|
||||||
@@ -419,6 +447,41 @@ Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str
|
|||||||
|
|
||||||
## Mudanças Recentes (14/03/2026)
|
## Mudanças Recentes (14/03/2026)
|
||||||
|
|
||||||
|
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
|
||||||
|
|
||||||
|
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
|
||||||
|
|
||||||
|
#### Novo query param `dpi`
|
||||||
|
|
||||||
|
Adicionado campo `dpi: Option<u32>` ao `LabelQuery` (handler). Aceito em `/label.svg` e `/label.pdf`. Valores recomendados:
|
||||||
|
|
||||||
|
| Impressora | `dpi` |
|
||||||
|
| ---------------- | ----- |
|
||||||
|
| Tela / padrão | omitir (96) |
|
||||||
|
| Niimbot D11/D110 | 203 |
|
||||||
|
| Brother / Dymo | 300 |
|
||||||
|
|
||||||
|
No SVG, `dpi` define `px_per_mm = dpi / 25.4` — o tamanho em pixels do arquivo corresponde à resolução física da impressora. No PDF, o `dpi` é ignorado (PDF já trabalha em mm), mas reservado para uso futuro.
|
||||||
|
|
||||||
|
#### Layout adaptativo — modo "mini"
|
||||||
|
|
||||||
|
Ativado quando `width_mm < 30` **ou** `height_mm < 20` (ex: Niimbot 22×14mm). Diferenças em relação ao modo padrão:
|
||||||
|
|
||||||
|
| Aspecto | Padrão | Mini |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Barra de cor | 3.5mm fixo | 11% da largura |
|
||||||
|
| QR size | 75% da altura | 80% da altura |
|
||||||
|
| Margem QR | 2mm | 1mm |
|
||||||
|
| Y do texto | `h - 8/14/20` (fixo, quebrava para h<20) | `h * 0.72/0.50/0.28` (proporcional) |
|
||||||
|
| Fontes | 7–9pt fixo | proporcionais à altura física |
|
||||||
|
| Rótulo peso | `"320g disponível"` | `"320g"` |
|
||||||
|
| Separador meta | ` · ` | ` ` (espaço) |
|
||||||
|
| `interpolate` no QR (PDF) | `true` | `false` (bordas nítidas em térmica) |
|
||||||
|
|
||||||
|
**Correção crítica**: no modo anterior, `Mm(h - 20.0)` gerava coordenada negativa para `h < 20mm` (Niimbot), colocando o texto fora da página.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
|
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
|
||||||
|
|
||||||
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
|
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ pub struct LabelQuery {
|
|||||||
/// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode
|
/// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode
|
||||||
/// Se ausente, inclui todos.
|
/// Se ausente, inclui todos.
|
||||||
pub fields: Option<String>,
|
pub fields: Option<String>,
|
||||||
|
/// DPI alvo da impressora. Padrão: 96 (tela). Use 203 para Niimbot/térmicas de baixo custo,
|
||||||
|
/// 300 para Brother/Dymo premium. Afeta resolução do QR Code no SVG e dimensionamento físico.
|
||||||
|
pub dpi: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_width_mm() -> u32 { 50 }
|
fn default_width_mm() -> u32 { 50 }
|
||||||
@@ -297,7 +300,7 @@ pub async fn export_label_pdf_handler(
|
|||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let pdf = state
|
let pdf = state
|
||||||
.filament_service
|
.filament_service
|
||||||
.generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
|
.generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let filename = format!("meowspool-label-{id}.pdf");
|
let filename = format!("meowspool-label-{id}.pdf");
|
||||||
@@ -321,7 +324,7 @@ pub async fn export_label_handler(
|
|||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let svg = state
|
let svg = state
|
||||||
.filament_service
|
.filament_service
|
||||||
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
|
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let filename = format!("meowspool-label-{id}.svg");
|
let filename = format!("meowspool-label-{id}.svg");
|
||||||
|
|||||||
@@ -291,19 +291,18 @@ impl FilamentService {
|
|||||||
width_mm: u32,
|
width_mm: u32,
|
||||||
height_mm: u32,
|
height_mm: u32,
|
||||||
fields: Option<&str>,
|
fields: Option<&str>,
|
||||||
|
dpi: Option<u32>,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let f = LabelFields::from_str(fields);
|
let f = LabelFields::from_str(fields);
|
||||||
let filament = self.get(id, user_id).await?;
|
let filament = self.get(id, user_id).await?;
|
||||||
|
|
||||||
// Gerar QR Code como PNG base64 para embutir no SVG
|
|
||||||
let qr_png = self.generate_qrcode_png(id, user_id).await?;
|
let qr_png = self.generate_qrcode_png(id, user_id).await?;
|
||||||
let qr_b64 = STANDARD.encode(&qr_png);
|
let qr_b64 = STANDARD.encode(&qr_png);
|
||||||
|
|
||||||
// Escala: 1mm = ~3.78px (96dpi)
|
// px_per_mm baseado no DPI alvo (padrão 96 para tela)
|
||||||
let px_per_mm = 3.78_f64;
|
let px_per_mm = dpi.unwrap_or(96) as f64 / 25.4;
|
||||||
let width_px = (width_mm as f64 * px_per_mm) as u32;
|
let width_px = (width_mm as f64 * px_per_mm).round() as u32;
|
||||||
let height_px = (height_mm as f64 * px_per_mm) as u32;
|
let height_px = (height_mm as f64 * px_per_mm).round() as u32;
|
||||||
let qr_size = (height_px as f64 * 0.8) as u32;
|
|
||||||
|
|
||||||
let model_name = filament.model.as_deref().unwrap_or("—");
|
let model_name = filament.model.as_deref().unwrap_or("—");
|
||||||
let net_weight = filament.net_weight_g;
|
let net_weight = filament.net_weight_g;
|
||||||
@@ -311,47 +310,88 @@ impl FilamentService {
|
|||||||
let brand = &filament.brand;
|
let brand = &filament.brand;
|
||||||
let color = &filament.color_hex;
|
let color = &filament.color_hex;
|
||||||
|
|
||||||
|
// Etiqueta "mini": largura < 30mm ou altura < 20mm (ex: Niimbot 22×14mm)
|
||||||
|
let is_mini = width_mm < 30 || height_mm < 20;
|
||||||
|
|
||||||
|
let (bar_w, text_x, qr_size, qr_x, qr_y, fs_name, fs_meta, fs_weight, y_name, y_meta, y_weight) =
|
||||||
|
if is_mini {
|
||||||
|
// Barra de cor estreita, QR ocupa 80% da altura, texto na faixa central
|
||||||
|
let bar_w = ((width_px as f64 * 0.09) as u32).max(3);
|
||||||
|
let qr_sz = ((height_px as f64 * 0.80) as u32).max(1);
|
||||||
|
let pad = ((width_px as f64 * 0.04) as u32).max(2);
|
||||||
|
let qr_x = width_px.saturating_sub(qr_sz + pad);
|
||||||
|
let qr_y = (height_px.saturating_sub(qr_sz)) / 2;
|
||||||
|
let text_x = (bar_w + pad * 2) as f64;
|
||||||
|
let fs_name = (height_px as f64 * 0.20).max(5.0).min(10.0);
|
||||||
|
let fs_meta = (height_px as f64 * 0.13).max(4.0).min(7.0);
|
||||||
|
let fs_weight = (height_px as f64 * 0.17).max(4.5).min(9.0);
|
||||||
|
let y_name = (height_px as f64 * 0.30) as u32;
|
||||||
|
let y_meta = (height_px as f64 * 0.54) as u32;
|
||||||
|
let y_weight = (height_px as f64 * 0.78) as u32;
|
||||||
|
(bar_w, text_x, qr_sz, qr_x, qr_y, fs_name, fs_meta, fs_weight, y_name, y_meta, y_weight)
|
||||||
|
} else {
|
||||||
|
// Layout padrão
|
||||||
|
let bar_w = ((width_px as f64 * 0.05) as u32).max(6);
|
||||||
|
let qr_sz = (height_px as f64 * 0.80) as u32;
|
||||||
|
let qr_x = width_px.saturating_sub(qr_sz + 4);
|
||||||
|
let qr_y = (height_px.saturating_sub(qr_sz)) / 2;
|
||||||
|
let text_x = (bar_w + 12) as f64;
|
||||||
|
let fs_name = (height_px as f64 * 0.19).max(8.0).min(14.0);
|
||||||
|
let fs_meta = (height_px as f64 * 0.14).max(6.0).min(11.0);
|
||||||
|
let fs_weight = (height_px as f64 * 0.17).max(7.0).min(12.0);
|
||||||
|
let y_name = (height_px as f64 * 0.33) as u32;
|
||||||
|
let y_meta = (height_px as f64 * 0.55) as u32;
|
||||||
|
let y_weight = (height_px as f64 * 0.77) as u32;
|
||||||
|
(bar_w, text_x, qr_sz, qr_x, qr_y, fs_name, fs_meta, fs_weight, y_name, y_meta, y_weight)
|
||||||
|
};
|
||||||
|
|
||||||
let color_swatch = if f.color {
|
let color_swatch = if f.color {
|
||||||
format!(r#" <rect x="6" y="6" width="14" height="{h}" fill="{color}" rx="2"/>"#,
|
let pad_v = (height_px as f64 * 0.08) as u32;
|
||||||
h = height_px - 12, color = color)
|
format!(
|
||||||
|
r#" <rect x="0" y="{pad_v}" width="{bar_w}" height="{h}" fill="{color}" rx="1"/>"#,
|
||||||
|
h = height_px.saturating_sub(pad_v * 2),
|
||||||
|
)
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
|
|
||||||
let text_name = if f.name {
|
let text_name = if f.name {
|
||||||
format!(r##" <text x="26" y="20" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#F5EEDC">{model_name}</text>"##,
|
format!(
|
||||||
model_name = escape_xml(model_name))
|
r##" <text x="{text_x}" y="{y_name}" font-family="Inter, sans-serif" font-size="{fs_name}" font-weight="700" fill="#F5EEDC">{model_name}</text>"##,
|
||||||
|
model_name = escape_xml(model_name)
|
||||||
|
)
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
|
|
||||||
let text_meta = if f.material_brand {
|
let text_meta = if f.material_brand {
|
||||||
format!(r##" <text x="26" y="33" font-family="Inter, sans-serif" font-size="8" fill="#C9C1B0">{brand} · {material}</text>"##,
|
let sep = if is_mini { " " } else { " · " };
|
||||||
brand = escape_xml(brand), material = escape_xml(&material))
|
format!(
|
||||||
|
r##" <text x="{text_x}" y="{y_meta}" font-family="Inter, sans-serif" font-size="{fs_meta}" fill="#C9C1B0">{brand}{sep}{material}</text>"##,
|
||||||
|
brand = escape_xml(brand), material = escape_xml(&material)
|
||||||
|
)
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
|
|
||||||
let text_weight = if f.net_weight {
|
let text_weight = if f.net_weight {
|
||||||
format!(r##" <text x="26" y="46" font-family="Inter, sans-serif" font-size="9" font-weight="600" fill="#38BCC2">{net_weight}g</text>"##,
|
format!(
|
||||||
net_weight = net_weight)
|
r##" <text x="{text_x}" y="{y_weight}" font-family="Inter, sans-serif" font-size="{fs_weight}" font-weight="600" fill="#38BCC2">{net_weight}g</text>"##,
|
||||||
|
)
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
|
|
||||||
let qr_element = if f.qrcode {
|
let qr_element = if f.qrcode {
|
||||||
format!(r##" <image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}" xlink:href="data:image/png;base64,{qr_b64}"/>"##,
|
format!(
|
||||||
qr_x = width_px - qr_size - 4,
|
r##" <image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}" xlink:href="data:image/png;base64,{qr_b64}"/>"##,
|
||||||
qr_y = (height_px - qr_size) / 2,
|
)
|
||||||
qr_size = qr_size,
|
|
||||||
qr_b64 = qr_b64)
|
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
|
|
||||||
|
let rx = if is_mini { 2 } else { 4 };
|
||||||
let svg = format!(
|
let svg = format!(
|
||||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
width="{width_px}px" height="{height_px}px" viewBox="0 0 {width_px} {height_px}">
|
width="{width_px}px" height="{height_px}px" viewBox="0 0 {width_px} {height_px}">
|
||||||
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="4"/>
|
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="{rx}"/>
|
||||||
{color_swatch}
|
{color_swatch}
|
||||||
{text_name}
|
{text_name}
|
||||||
{text_meta}
|
{text_meta}
|
||||||
{text_weight}
|
{text_weight}
|
||||||
{qr_element}
|
{qr_element}
|
||||||
</svg>"##,
|
</svg>"##,
|
||||||
width_px = width_px,
|
|
||||||
height_px = height_px,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(svg)
|
Ok(svg)
|
||||||
@@ -368,6 +408,7 @@ impl FilamentService {
|
|||||||
width_mm: u32,
|
width_mm: u32,
|
||||||
height_mm: u32,
|
height_mm: u32,
|
||||||
fields: Option<&str>,
|
fields: Option<&str>,
|
||||||
|
_dpi: Option<u32>,
|
||||||
) -> Result<Vec<u8>, AppError> {
|
) -> Result<Vec<u8>, AppError> {
|
||||||
let f = LabelFields::from_str(fields);
|
let f = LabelFields::from_str(fields);
|
||||||
use printpdf::{
|
use printpdf::{
|
||||||
@@ -378,7 +419,6 @@ impl FilamentService {
|
|||||||
|
|
||||||
let filament = self.get(id, user_id).await?;
|
let filament = self.get(id, user_id).await?;
|
||||||
|
|
||||||
// QR Code como pixels luma — sem depender do feature image do printpdf
|
|
||||||
let qr_png = self.generate_qrcode_png(id, user_id).await?;
|
let qr_png = self.generate_qrcode_png(id, user_id).await?;
|
||||||
let qr_img = image::load_from_memory(&qr_png)
|
let qr_img = image::load_from_memory(&qr_png)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("QR decode: {e}")))?
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("QR decode: {e}")))?
|
||||||
@@ -389,6 +429,9 @@ impl FilamentService {
|
|||||||
let w = width_mm as f32;
|
let w = width_mm as f32;
|
||||||
let h = height_mm as f32;
|
let h = height_mm as f32;
|
||||||
|
|
||||||
|
// Etiqueta "mini": largura < 30mm ou altura < 20mm (ex: Niimbot 22×14mm)
|
||||||
|
let is_mini = w < 30.0 || h < 20.0;
|
||||||
|
|
||||||
let (doc, page1, layer1) = PdfDocument::new(
|
let (doc, page1, layer1) = PdfDocument::new(
|
||||||
format!("MeowSpool — {}", filament.brand),
|
format!("MeowSpool — {}", filament.brand),
|
||||||
Mm(w),
|
Mm(w),
|
||||||
@@ -397,7 +440,6 @@ impl FilamentService {
|
|||||||
);
|
);
|
||||||
let layer = doc.get_page(page1).get_layer(layer1);
|
let layer = doc.get_page(page1).get_layer(layer1);
|
||||||
|
|
||||||
// Helper de retângulo preenchido via Polygon
|
|
||||||
let filled_rect = |x: f32, y: f32, rw: f32, rh: f32| Polygon {
|
let filled_rect = |x: f32, y: f32, rw: f32, rh: f32| Polygon {
|
||||||
rings: vec![vec![
|
rings: vec![vec![
|
||||||
(Point::new(Mm(x), Mm(y)), false),
|
(Point::new(Mm(x), Mm(y)), false),
|
||||||
@@ -410,32 +452,36 @@ impl FilamentService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Fundo escuro ──────────────────────────────────────────
|
// ── Fundo escuro ──────────────────────────────────────────
|
||||||
layer.set_fill_color(Color::Rgb(Rgb::new(0.118, 0.106, 0.094, None))); // #1E1B18
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.118, 0.106, 0.094, None)));
|
||||||
layer.add_polygon(filled_rect(0.0, 0.0, w, h));
|
layer.add_polygon(filled_rect(0.0, 0.0, w, h));
|
||||||
|
|
||||||
// ── Barra de cor ─────────────────────────────────────────
|
// ── Barra de cor ──────────────────────────────────────────
|
||||||
|
let bar_w = if is_mini { (w * 0.11).max(1.5) } else { 3.5_f32 };
|
||||||
if f.color {
|
if f.color {
|
||||||
let hex = filament.color_hex.trim_start_matches('#');
|
let hex = filament.color_hex.trim_start_matches('#');
|
||||||
let cr = u8::from_str_radix(hex.get(0..2).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
let cr = u8::from_str_radix(hex.get(0..2).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
||||||
let cg = u8::from_str_radix(hex.get(2..4).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
let cg = u8::from_str_radix(hex.get(2..4).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
||||||
let cb = u8::from_str_radix(hex.get(4..6).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
let cb = u8::from_str_radix(hex.get(4..6).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
||||||
layer.set_fill_color(Color::Rgb(Rgb::new(cr, cg, cb, None)));
|
layer.set_fill_color(Color::Rgb(Rgb::new(cr, cg, cb, None)));
|
||||||
layer.add_polygon(filled_rect(0.0, 0.0, 3.5, h));
|
layer.add_polygon(filled_rect(0.0, 0.0, bar_w, h));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── QR Code ───────────────────────────────────────────────
|
// ── QR Code ───────────────────────────────────────────────
|
||||||
if f.qrcode {
|
// Mini: QR ocupa 80% da altura e fica colado à direita.
|
||||||
let qr_size_mm = h * 0.75;
|
// Padrão: QR ocupa 75% da altura com margem de 2mm.
|
||||||
let qr_x = w - qr_size_mm - 2.0;
|
let qr_size_mm = if is_mini { h * 0.80 } else { h * 0.75 };
|
||||||
|
let qr_margin = if is_mini { 1.0_f32 } else { 2.0_f32 };
|
||||||
|
let qr_x = w - qr_size_mm - qr_margin;
|
||||||
let qr_y = (h - qr_size_mm) / 2.0;
|
let qr_y = (h - qr_size_mm) / 2.0;
|
||||||
let qr_dpi = qr_px_w as f32 * 25.4 / qr_size_mm;
|
|
||||||
|
|
||||||
|
if f.qrcode {
|
||||||
|
let qr_dpi = qr_px_w as f32 * 25.4 / qr_size_mm;
|
||||||
let qr_xobj = ImageXObject {
|
let qr_xobj = ImageXObject {
|
||||||
width: Px(qr_px_w as usize),
|
width: Px(qr_px_w as usize),
|
||||||
height: Px(qr_px_h as usize),
|
height: Px(qr_px_h as usize),
|
||||||
color_space: ColorSpace::Greyscale,
|
color_space: ColorSpace::Greyscale,
|
||||||
bits_per_component: ColorBits::Bit8,
|
bits_per_component: ColorBits::Bit8,
|
||||||
interpolate: true,
|
interpolate: false, // false = bordas nítidas em impressoras térmicas
|
||||||
image_data: qr_raw,
|
image_data: qr_raw,
|
||||||
image_filter: None,
|
image_filter: None,
|
||||||
clipping_bbox: None,
|
clipping_bbox: None,
|
||||||
@@ -455,39 +501,54 @@ impl FilamentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Texto ─────────────────────────────────────────────────
|
// ── Texto ─────────────────────────────────────────────────
|
||||||
|
// text_x: começa após a barra de cor com pequena margem
|
||||||
|
// text_max_x: limite antes do QR (ou borda direita se sem QR)
|
||||||
|
let text_x = bar_w + if is_mini { 1.0 } else { 1.5 };
|
||||||
|
let text_max_x = if f.qrcode { qr_x - 0.5 } else { w - 1.0 };
|
||||||
|
|
||||||
|
// Distribuição vertical uniforme dentro da etiqueta (3 linhas)
|
||||||
|
// PDF: y=0 é a borda inferior, y=h é o topo.
|
||||||
|
// Dividimos h em 4 partes: 75%, 50%, 25% do topo (→ 25%, 50%, 75% da base)
|
||||||
|
let y_name = h * 0.72;
|
||||||
|
let y_meta = h * 0.50;
|
||||||
|
let y_weight = h * 0.28;
|
||||||
|
|
||||||
|
// Tamanhos de fonte adaptativos ao tamanho físico da etiqueta
|
||||||
|
let fs_name = if is_mini { (h * 0.32).max(4.5).min(7.0) } else { (h * 0.28).max(7.0).min(11.0) };
|
||||||
|
let fs_meta = if is_mini { (h * 0.22).max(3.5).min(5.5) } else { (h * 0.20).max(5.5).min(8.5) };
|
||||||
|
let fs_weight = if is_mini { (h * 0.27).max(4.0).min(6.5) } else { (h * 0.24).max(6.5).min(10.0) };
|
||||||
|
|
||||||
let needs_text = f.name || f.material_brand || f.net_weight;
|
let needs_text = f.name || f.material_brand || f.net_weight;
|
||||||
if needs_text {
|
if needs_text && text_x < text_max_x {
|
||||||
let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)
|
let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
|
||||||
let font = doc.add_builtin_font(BuiltinFont::Helvetica)
|
let font = doc.add_builtin_font(BuiltinFont::Helvetica)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
|
||||||
|
|
||||||
let text_x = 5.0_f32;
|
|
||||||
|
|
||||||
if f.name {
|
if f.name {
|
||||||
let model_name = filament.model.as_deref().unwrap_or(&filament.brand);
|
let model_name = filament.model.as_deref().unwrap_or(&filament.brand);
|
||||||
layer.set_fill_color(Color::Rgb(Rgb::new(0.961, 0.933, 0.863, None)));
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.961, 0.933, 0.863, None)));
|
||||||
layer.use_text(model_name, 9.0, Mm(text_x), Mm(h - 8.0), &font_bold);
|
layer.use_text(model_name, fs_name, Mm(text_x), Mm(y_name), &font_bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.material_brand {
|
if f.material_brand {
|
||||||
let meta = format!("{} · {}", filament.brand, filament.material);
|
let sep = if is_mini { " " } else { " · " };
|
||||||
|
let meta = format!("{}{}{}", filament.brand, sep, filament.material);
|
||||||
layer.set_fill_color(Color::Rgb(Rgb::new(0.788, 0.757, 0.690, None)));
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.788, 0.757, 0.690, None)));
|
||||||
layer.use_text(&meta, 7.0, Mm(text_x), Mm(h - 14.0), &font);
|
layer.use_text(&meta, fs_meta, Mm(text_x), Mm(y_meta), &font);
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.net_weight {
|
if f.net_weight {
|
||||||
|
let label = if is_mini {
|
||||||
|
format!("{:.0}g", filament.net_weight_g)
|
||||||
|
} else {
|
||||||
|
format!("{:.0}g disponível", filament.net_weight_g)
|
||||||
|
};
|
||||||
layer.set_fill_color(Color::Rgb(Rgb::new(0.220, 0.737, 0.761, None)));
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.220, 0.737, 0.761, None)));
|
||||||
layer.use_text(
|
layer.use_text(&label, fs_weight, Mm(text_x), Mm(y_weight), &font_bold);
|
||||||
&format!("{:.0}g disponível", filament.net_weight_g),
|
|
||||||
8.0,
|
|
||||||
Mm(text_x),
|
|
||||||
Mm(h - 20.0),
|
|
||||||
&font_bold,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = CurTransMat::Identity; // suprime unused import
|
let _ = CurTransMat::Identity;
|
||||||
|
|
||||||
// ── Serializar ────────────────────────────────────────────
|
// ── Serializar ────────────────────────────────────────────
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
|
|||||||
+52
-2
@@ -320,8 +320,8 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
| PATCH | `/filaments/:id` | Atualizar |
|
| PATCH | `/filaments/:id` | Atualizar |
|
||||||
| DELETE | `/filaments/:id` | Excluir |
|
| DELETE | `/filaments/:id` | Excluir |
|
||||||
| GET | `/filaments/:id/qrcode` | QR Code PNG |
|
| GET | `/filaments/:id/qrcode` | QR Code PNG |
|
||||||
| GET | `/filaments/:id/label.svg` | Etiqueta SVG (`width_mm`, `height_mm`, `fields`) |
|
| GET | `/filaments/:id/label.svg` | Etiqueta SVG (`width_mm`, `height_mm`, `fields`, `dpi`) |
|
||||||
| GET | `/filaments/:id/label.pdf` | Etiqueta PDF (`width_mm`, `height_mm`, `fields`) |
|
| GET | `/filaments/:id/label.pdf` | Etiqueta PDF (`width_mm`, `height_mm`, `fields`, `dpi`) |
|
||||||
|
|
||||||
### Spool Presets
|
### Spool Presets
|
||||||
|
|
||||||
@@ -349,6 +349,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
- [x] Exportar etiqueta SVG via `GET /filaments/:id/label.svg`
|
- [x] Exportar etiqueta SVG via `GET /filaments/:id/label.svg`
|
||||||
- [x] Exportar etiqueta PDF via `GET /filaments/:id/label.pdf`
|
- [x] Exportar etiqueta PDF via `GET /filaments/:id/label.pdf`
|
||||||
- [x] Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta
|
- [x] Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta
|
||||||
|
- [x] Suporte a Niimbot D11/D110 (22×14mm) com seleção automática de conteúdo mínimo
|
||||||
- [x] NFC leitura NTAG215 → `nfc-reader.tsx` (bottom sheet modal)
|
- [x] NFC leitura NTAG215 → `nfc-reader.tsx` (bottom sheet modal)
|
||||||
- [x] NFC gravação NTAG215 → `filaments/[id]/write-nfc.tsx`
|
- [x] NFC gravação NTAG215 → `filaments/[id]/write-nfc.tsx`
|
||||||
- [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`)
|
- [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`)
|
||||||
@@ -360,6 +361,55 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
|
|
||||||
## Mudanças Recentes (14/03/2026)
|
## Mudanças Recentes (14/03/2026)
|
||||||
|
|
||||||
|
### ✅ Suporte a Impressoras Pequenas — Niimbot D11/D110 (`label.tsx`)
|
||||||
|
|
||||||
|
**Arquivo alterado**: `app/(app)/filaments/[id]/label.tsx`
|
||||||
|
|
||||||
|
#### Preset Niimbot 22×14mm
|
||||||
|
|
||||||
|
Adicionado como primeira opção em `LABEL_SIZES`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ id: '22x14', label: '22 × 14', sub: 'Niimbot D11/D110' }
|
||||||
|
```
|
||||||
|
|
||||||
|
O tipo `LabelSize` foi atualizado para incluir `'22x14'`.
|
||||||
|
|
||||||
|
#### Seleção automática de conteúdo mínimo
|
||||||
|
|
||||||
|
A função `handleSizeChange` substitui o `setSelectedSize` direto nos chips. Ao selecionar `22x14`, o conteúdo ativo é automaticamente reduzido para `{ color, name, qrcode }` — os únicos campos que cabem fisicamente em 22×14mm. O usuário pode ajustar manualmente após.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MINI_LABEL_CONTENT: Set<ContentOption> = new Set(['color', 'name', 'qrcode']);
|
||||||
|
|
||||||
|
function handleSizeChange(size: LabelSize): void {
|
||||||
|
setSelectedSize(size);
|
||||||
|
if (size === '22x14') setEnabledContent(new Set(MINI_LABEL_CONTENT));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `dpi=203` enviado para o backend
|
||||||
|
|
||||||
|
O parâmetro `dpi` é incluído automaticamente nos requests de `/label.pdf` e `/label.svg` quando Niimbot está selecionado. Instrui o backend a calcular pixels do SVG na resolução correta da impressora térmica.
|
||||||
|
|
||||||
|
#### Fix: btoa chunking para PDFs grandes
|
||||||
|
|
||||||
|
O código original usava `String.fromCharCode(...new Uint8Array(buffer))` com spread, que causa **stack overflow** em buffers > ~64KB em React Native. Substituído por loop em chunks de 8192 bytes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const bytes = new Uint8Array(response.data);
|
||||||
|
let binary = '';
|
||||||
|
const CHUNK = 8192;
|
||||||
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
```
|
||||||
|
|
||||||
|
Essa correção se aplica a todos os tamanhos de etiqueta, não apenas Niimbot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### ✅ NFC Read/Write — NTAG215
|
### ✅ NFC Read/Write — NTAG215
|
||||||
|
|
||||||
Implementação completa de leitura e gravação NFC para tags NTAG215. O conteúdo gravado é idêntico ao QR Code: `meowspool://filament/{id}` como NDEF URI record.
|
Implementação completa de leitura e gravação NFC para tags NTAG215. O conteúdo gravado é idêntico ao QR Code: `meowspool://filament/{id}` como NDEF URI record.
|
||||||
|
|||||||
@@ -15,14 +15,18 @@ import { formatWeight } from '@shared/utils/filament';
|
|||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
import { httpClient } from '@adapters/remote/httpClient';
|
import { httpClient } from '@adapters/remote/httpClient';
|
||||||
|
|
||||||
type LabelSize = '50x30' | '62x29' | '38x25';
|
type LabelSize = '22x14' | '50x30' | '62x29' | '38x25';
|
||||||
|
|
||||||
const LABEL_SIZES: { id: LabelSize; label: string; sub: string }[] = [
|
const LABEL_SIZES: { id: LabelSize; label: string; sub: string }[] = [
|
||||||
|
{ id: '22x14', label: '22 × 14', sub: 'Niimbot D11/D110' },
|
||||||
{ id: '50x30', label: '50 × 30', sub: 'Padrão' },
|
{ id: '50x30', label: '50 × 30', sub: 'Padrão' },
|
||||||
{ id: '62x29', label: '62 × 29', sub: 'Brother DK' },
|
{ id: '62x29', label: '62 × 29', sub: 'Brother DK' },
|
||||||
{ id: '38x25', label: '38 × 25', sub: 'Dymo 11354' },
|
{ id: '38x25', label: '38 × 25', sub: 'Dymo 11354' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Conteúdo mínimo para etiquetas muito pequenas (Niimbot)
|
||||||
|
const MINI_LABEL_CONTENT: Set<ContentOption> = new Set(['color', 'name', 'qrcode']);
|
||||||
|
|
||||||
type ContentOption = 'color' | 'name' | 'material_brand' | 'net_weight' | 'print_temp' | 'qrcode';
|
type ContentOption = 'color' | 'name' | 'material_brand' | 'net_weight' | 'print_temp' | 'qrcode';
|
||||||
|
|
||||||
const CONTENT_OPTIONS: { id: ContentOption; label: string }[] = [
|
const CONTENT_OPTIONS: { id: ContentOption; label: string }[] = [
|
||||||
@@ -64,6 +68,13 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const pct = calcFilamentPercentage(filament);
|
const pct = calcFilamentPercentage(filament);
|
||||||
|
|
||||||
|
function handleSizeChange(size: LabelSize): void {
|
||||||
|
setSelectedSize(size);
|
||||||
|
if (size === '22x14') {
|
||||||
|
setEnabledContent(new Set(MINI_LABEL_CONTENT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleContent(opt: ContentOption): void {
|
function toggleContent(opt: ContentOption): void {
|
||||||
setEnabledContent((prev) => {
|
setEnabledContent((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -78,16 +89,24 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
const height_mm = Number(heightStr);
|
const height_mm = Number(heightStr);
|
||||||
const isPdf = selectedFormat === 'pdf';
|
const isPdf = selectedFormat === 'pdf';
|
||||||
const fields = Array.from(enabledContent).join(',');
|
const fields = Array.from(enabledContent).join(',');
|
||||||
|
// DPI alvo: 203 para Niimbot (impressora térmica de baixo custo), 96 para tela
|
||||||
|
const dpi = selectedSize === '22x14' ? 203 : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
const response = await httpClient.get<ArrayBuffer>(
|
const response = await httpClient.get<ArrayBuffer>(
|
||||||
`/filaments/${id}/label.pdf`,
|
`/filaments/${id}/label.pdf`,
|
||||||
{ params: { width_mm, height_mm, fields }, responseType: 'arraybuffer' },
|
{ params: { width_mm, height_mm, fields, dpi }, responseType: 'arraybuffer' },
|
||||||
);
|
|
||||||
const base64 = btoa(
|
|
||||||
String.fromCharCode(...new Uint8Array(response.data)),
|
|
||||||
);
|
);
|
||||||
|
// Converte ArrayBuffer para Base64 em chunks para evitar stack overflow
|
||||||
|
// em buffers grandes (String.fromCharCode com spread falha acima de ~64KB)
|
||||||
|
const bytes = new Uint8Array(response.data);
|
||||||
|
let binary = '';
|
||||||
|
const CHUNK = 8192;
|
||||||
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.pdf`;
|
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.pdf`;
|
||||||
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
@@ -100,7 +119,7 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
const response = await httpClient.get<string>(
|
const response = await httpClient.get<string>(
|
||||||
`/filaments/${id}/label.svg`,
|
`/filaments/${id}/label.svg`,
|
||||||
{ params: { width_mm, height_mm, fields }, responseType: 'text' },
|
{ params: { width_mm, height_mm, fields, dpi }, responseType: 'text' },
|
||||||
);
|
);
|
||||||
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
|
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
|
||||||
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
|
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
|
||||||
@@ -180,7 +199,7 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
{LABEL_SIZES.map((s) => (
|
{LABEL_SIZES.map((s) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={s.id}
|
key={s.id}
|
||||||
onPress={() => setSelectedSize(s.id)}
|
onPress={() => handleSizeChange(s.id)}
|
||||||
style={[styles.sizeChip, selectedSize === s.id && styles.sizeChipActive]}
|
style={[styles.sizeChip, selectedSize === s.id && styles.sizeChipActive]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.sizeChipMain, selectedSize === s.id && styles.sizeChipMainActive]}>
|
<Text style={[styles.sizeChipMain, selectedSize === s.id && styles.sizeChipMainActive]}>
|
||||||
|
|||||||
Reference in New Issue
Block a user