Skip to main content

Design Document: [Front] Novo layout do card de produto

Overview

Esta task implementa um novo widget de card de produto para a tela CartStatusDetailScreen, substituindo CartStatusProductCard somente na seção "Itens" (_ContentProducts). O componente legado permanece intacto para as demais telas.

A abordagem separa apresentação (widget Flutter) de regras de negócio de desconto (mapper puro em Dart), com um view model interno (ProductCardViewModel) que centraliza os valores já calculados para renderização. O fluxo segue o padrão existente da tela: CartController.findFutureBuilder_ContentProducts → novo card.

O componente expõe factory constructors para duas variantes — orderItem e recommended — preparando a integração do bloco "Recomendados" na task 196865, sem implementá-la nesta entrega.

Contexto

A US 196869 (MVP upsell na tela de Status do Pedido — PT. 2) exige atualização visual dos cards conforme Figma, cobrindo itens do pedido e itens recomendados. Esta task (196866) entrega o componente reutilizável e sua integração na seção "Itens"; a dinâmica do bloco "Recomendados" fica na task 196865.

Contrato de referência: cart-detail.json

Componente AS-IS: CartStatusProductCard em lib/screens/cart_status/widgets/cart_status_product_card.dart

Requisitos EARS: task-196866-front-novo-layout-do-card-de-produto-requisitos.md

Decisões de Design

1. Novo widget em arquivo separado (não alterar legado)

Contexto: RF-01 exige coexistência do componente legado sem alterações.

Opções consideradas:

OpçãoPrósContras
A — Refatorar CartStatusProductCard in-placeMenos arquivosViola RF-01.4; risco em outras telas
B — Novo widget + legado intactoEscopo isolado; validação gradualDuplicação temporária de layout

Decisão: Opção B — criar CartStatusProductCardNew em arquivo dedicado.

Racional: Alinha com RF-01 e permite substituição global futura após validação.


2. View model interno + mapper puro para lógica de desconto

Contexto: RF-04 a RF-08 definem regras distintas para orderItem e recommended, com múltiplas combinações de entrada.

Opções consideradas:

OpçãoPrósContras
A — Lógica inline no build()Implementação rápidaDifícil de testar; build() inchado
B — Mapper puro + ProductCardViewModelTestável; widget só renderizaUm arquivo a mais
C — Estender ProductCartDetail com getters de displayDados e display acopladosPolui model de domínio com labels de UI

Decisão: Opção B — ProductCardDiscountMapper (funções puras) + ProductCardViewModel (DTO de apresentação).

Racional: Segue padrão do projeto de manter models (ProductCartDetail) focados em deserialização; lógica de label/cor/prefixo fica testável sem widget test.

Referência no projeto: o padrão mais próximo é o ZzProgressCard (lib/theme_widgets/progress_card/progress_card.dart). O widget recebe props já preparadas para display — em especial ZzProgressCardCornerTextFormatGoal, que encapsula a formatação do texto de canto fora do build(). As telas de schedule (day_schedules_list.dart, activation_progress.dart) constroem o objeto de display e passam ao card. O novo card evolui esse padrão ao extrair a lógica de desconto para um mapper dedicado (ProductCardDiscountMapper), já que as regras de RF-04 a RF-08 são mais complexas que a formatação do progress card.


Contexto: A API retorna cartItemRecommendations com estrutura diferente de productCartDetails (sem quantity, total, discountOrigin, fromRecommendation).

Opções consideradas:

OpçãoPrósContras
A — Reutilizar ProductCartDetail com defaultsUm model sóCampos obrigatórios sem sentido (total, quantity)
B — Criar CartItemRecommendationTipagem correta; prepara task 196865Arquivo adicional

Decisão: Opção B — model dedicado em lib/models/cart/cart_item_recommendation.dart.

Racional: Factory recommended recebe dados tipados; task 196865 reutiliza o mesmo model ao integrar o bloco.


4. Tag de recomendação como componente reutilizável em theme_widgets

Contexto: RF-09 exige faixa verde full-width com ícone de confirmação (Figma), diferente do ZzTag existente (pill compacto). O banner pode ser reutilizado em outras composições (ex.: task 196865, futuros cards de upsell).

Opções consideradas:

OpçãoPrósContras
A — Reutilizar ZzTag com ZzTagTheme.successComponente existenteLayout pill ≠ faixa full-width do Figma
B — _RecommendedAddedBanner privado no cardFiel ao Figma; escopo localNão reutilizável fora do card
C — ZzConfirmationBanner em theme_widgets/banner/Reutilizável; fiel ao Figma; alinhado a BannerWidget / ZzTagArquivo adicional no design system

Decisão: Opção C — criar ZzConfirmationBanner em lib/theme_widgets/banner/zz_confirmation_banner.dart.

Racional: RF-09.1 referencia layout específico; o componente fica disponível para qualquer tela que precise de faixa de confirmação com ícone, seguindo a convenção de banners em theme_widgets/banner/.


5. Remover linha "Remarcação"; consolidar em DiscountType.sale

Contexto: RF-02.4 elimina a linha "Remarcação" do layout AS-IS; desconto de markdown passa a DiscountType.sale via discountOrigin (RF-05).

Decisão: O novo card não renderiza discountMarkdown como linha separada. O valor de sale é calculado via fullPrice - price quando aplicável (RF-04.5, RF-07.4).

Racional: Substitui comportamento legado de CartStatusProductCard (linhas 99–117) por modelo unificado de desconto.

Architecture

Diagrama de Fluxo

Camadas e Responsabilidades

CamadaComponenteTipoResponsabilidade
ScreenCartStatusDetailScreenModificadoMantém fetch AS-IS; troca widget em _ContentProducts (RF-11)
WidgetCartStatusProductCardNewNovoLayout Figma; factories orderItem / recommended; consome ZzConfirmationBanner (RF-09)
Design SystemZzConfirmationBannerNovoFaixa full-width reutilizável com ícone de confirmação e tema success
UtilsProductCardDiscountMapperNovoMapeamento DiscountType, formatação de valores, preço final (RF-04–08)
View ModelProductCardViewModelNovoDTO imutável com campos prontos para renderização
ModelProductCartDetailModificadoNovos campos API: discountOrigin, fromRecommendation, hasEmployeeDiscount (RF-10)
ModelCartItemRecommendationNovoDeserialização de cartItemRecommendations para factory recommended
EnumDiscountTypeNovofuncionario, manual, sale, none (RF-03)
EnumCartItemDiscountOriginNovoNone, Markdown, Manual, Both (RF-05, RF-10)
LegadoCartStatusProductCardExistente (sem alteração)Continua em demais telas (RF-01.4)

Components and Interfaces

Novos Arquivos

ArquivoCamadaTipo
lib/theme_widgets/banner/zz_confirmation_banner.dartDesign SystemFaixa de confirmação reutilizável (RF-09)
lib/screens/cart_status/widgets/cart_status_product_card_new.dartWidgetStatelessWidget com factories
lib/screens/cart_status/utils/product_card_discount_mapper.dartUtilsFunções puras de mapeamento
lib/screens/cart_status/models/product_card_view_model.dartView ModelDTO de apresentação
lib/shared/enum/discount_type.dartEnumDiscountType + extensão de label
lib/shared/enum/cart_item_discount_origin.dartEnumCartItemDiscountOrigin + parser fromJson
lib/models/cart/cart_item_recommendation.dartModelItem de cartItemRecommendations
test/screens/cart_status/utils/product_card_discount_mapper_test.dartTestCobertura RF-04 a RF-08
test/models/cart/cart_item_recommendation_test.dartTestDeserialização do model

Arquivos Modificados

ArquivoModificação
lib/models/product/product_cart_detail.dartAdicionar discountOrigin, fromRecommendation, hasEmployeeDiscount; defaults seguros no fromJson (RF-10)
lib/screens/cart_status/cart_status_detail_screen.dartEm _ContentProducts, trocar CartStatusProductCard por CartStatusProductCardNew.orderItem (RF-01.3)
lib/theme_widgets/coezzion_design_flutter.dartExportar ZzConfirmationBanner
test/models/product/product_cart_detail_test.dartTestes dos novos campos e defaults (RF-10)

Interfaces

// RF-03 — lib/shared/enum/discount_type.dart
enum DiscountType { funcionario, manual, sale, none }

extension DiscountTypeLabel on DiscountType {
String get label {
switch (this) {
case DiscountType.none:
return 'Desconto';
case DiscountType.manual:
return 'Desconto manual';
case DiscountType.funcionario:
return 'Desconto funcionário';
case DiscountType.sale:
return 'Desconto sale';
}
}
}

// RF-05, RF-10 — lib/shared/enum/cart_item_discount_origin.dart
enum CartItemDiscountOrigin { none, markdown, manual, both }

abstract class CartItemDiscountOriginParser {
static CartItemDiscountOrigin fromJson(String? value) {
switch (value) {
case 'Markdown':
return CartItemDiscountOrigin.markdown;
case 'Manual':
return CartItemDiscountOrigin.manual;
case 'Both':
return CartItemDiscountOrigin.both;
default:
return CartItemDiscountOrigin.none;
}
}
}

// RF-04 a RF-08 — lib/screens/cart_status/utils/product_card_discount_mapper.dart
class DiscountDisplay {
final String formattedValue; // ex: "R$ 0,00", "- R$ 10,00", "- R$ 95,96"
final Color valueColor; // ZZColors.neutralDark | ZZColors.successMedium
final bool showMinusPrefix;
}

abstract class ProductCardDiscountMapper {
// RF-05
static DiscountType mapDiscountTypeFromOrderItem(ProductCartDetail item);

// RF-07
static DiscountType mapDiscountTypeFromRecommended(CartItemRecommendation item);

// RF-04
static DiscountDisplay formatDiscountDisplay({
required DiscountType discountType,
required int discount,
required double discountValue,
required double fullPrice,
required double price,
});

// RF-08
static double calculateFinalPriceRecommended(CartItemRecommendation item);
}

// RF-01 — lib/screens/cart_status/widgets/cart_status_product_card_new.dart
class CartStatusProductCardNew extends StatelessWidget {
final ProductCardViewModel _viewModel;

const CartStatusProductCardNew._(this._viewModel, {super.key});

factory CartStatusProductCardNew.orderItem(ProductCartDetail product) {
return CartStatusProductCardNew._(
ProductCardViewModel.fromOrderItem(product),
);
}

factory CartStatusProductCardNew.recommended(CartItemRecommendation product) {
return CartStatusProductCardNew._(
ProductCardViewModel.fromRecommended(product),
);
}
}

// RF-09 — lib/theme_widgets/banner/zz_confirmation_banner.dart
class ZzConfirmationBanner extends StatelessWidget {
final String text;
final IconData icon;

const ZzConfirmationBanner({
super.key,
required this.text,
this.icon = PhosphorIcons.check, // ícone padrão de confirmação
});
}

Data Models

Entidades envolvidas

// RF-10 — lib/models/product/product_cart_detail.dart (campos adicionados)
class ProductCartDetail implements DefaultModelInterface {
// ... campos existentes ...
CartItemDiscountOrigin discountOrigin;
bool fromRecommendation;
bool hasEmployeeDiscount;

factory ProductCartDetail.fromJson(Map<String, dynamic> json) {
return ProductCartDetail(
// ... campos existentes ...
discountOrigin: CartItemDiscountOriginParser.fromJson(
pick(json, 'discountOrigin').asStringOrNull(),
),
fromRecommendation: pick(json, 'fromRecommendation').asBoolOrDefault(
defaultValue: false,
),
hasEmployeeDiscount: pick(json, 'hasEmployeeDiscount').asBoolOrDefault(
defaultValue: false,
),
);
}
}

// lib/models/cart/cart_item_recommendation.dart
class CartItemRecommendation implements DefaultModelInterface {
int productId;
String name;
String image;
double price;
String sku;
String size;
int discount;
double discountValue;
double fullPrice;
bool hasEmployeeDiscount;

factory CartItemRecommendation.fromJson(Map<String, dynamic> json) { /* ... */ }
}

// lib/screens/cart_status/models/product_card_view_model.dart
class ProductCardViewModel {
final String name;
final String image;
final String sku;
final String quantityLabel;
final String size;
final double valor; // fullPrice
final DiscountType discountType;
final DiscountDisplay discountDisplay;
final double precoFinal;
final bool showRecommendationTag; // fromRecommendation (RF-09)

factory ProductCardViewModel.fromOrderItem(ProductCartDetail product) { /* RF-05, RF-06, RF-09 */ }
factory ProductCardViewModel.fromRecommended(CartItemRecommendation product) { /* RF-07, RF-08 */ }
}

Diagrama de Relacionamento

Mapeamento RF-05 (discountOriginDiscountType)

discountOriginhasEmployeeDiscountDiscountType
Nonenone
Markdownsale
Manualfalsemanual
Manualtruefuncionario
Bothfalsemanual
Bothtruefuncionario

Para Markdown/sale, o valor exibido na linha de desconto segue RF-04 (baseado em discount + discountValue), não fullPrice - price.

Error Handling

Tabela de Cenários

CenárioComportamentoRF
Campo discountOrigin ausente no JSONDefault CartItemDiscountOrigin.noneRF-10.3
Campo fromRecommendation ausenteDefault false; tag não exibidaRF-10.3, RF-09.2
Campo hasEmployeeDiscount ausenteDefault falseRF-10.3
discountOrigin com valor desconhecidoTratar como noneRF-10.3
Nome, imagem ou SKU vaziosRenderizar campos vazios; layout preservadoRF-01 (borda)
discountValue zero com discount > 0Exibir R$ 0,00 sem prefixo -, cor neutralDarkRF-04.4
sale com fullPrice - price ≤ 0Exibir R$ 0,00 sem prefixo -RF-04.4, RF-07.5
Imagem com URL inválidaZzUrlImage exibe placeholder (comportamento existente)

Fluxo de Renderização (Pseudocódigo)

// RF-01, RF-02, RF-06, RF-09, RF-11
Widget build(BuildContext context) {
return ZzCard.regular(
child: Column(
children: [
Row(
children: [
ZzUrlImage(url: _viewModel.image),
Expanded(child: _buildProductInfo()),
],
),
_buildPriceRows(), // Valor, Desconto (label dinâmico), Preço final
if (_viewModel.showRecommendationTag)
const ZzConfirmationBanner(
text: 'Item recomendado adicionado ao pedido',
), // RF-09
],
),
);
// Sem GestureDetector / onTap — RF-11.1
}

// RF-05 + RF-04
ProductCardViewModel fromOrderItem(ProductCartDetail product) {
final discountType = ProductCardDiscountMapper.mapDiscountTypeFromOrderItem(product);
final discountDisplay = ProductCardDiscountMapper.formatDiscountDisplay(
discountType: discountType,
discount: product.discount,
discountValue: product.discountValue,
fullPrice: product.fullPrice,
price: product.price,
);
return ProductCardViewModel(
valor: product.fullPrice, // RF-06.1
precoFinal: product.total, // RF-06.2
quantityLabel: product.quantity.toString(), // RF-06.3
discountType: discountType,
discountDisplay: discountDisplay,
showRecommendationTag: product.fromRecommendation, // RF-09
// ...
);
}

// RF-07 + RF-08
ProductCardViewModel fromRecommended(CartItemRecommendation product) {
final discountType = ProductCardDiscountMapper.mapDiscountTypeFromRecommended(product);
final precoFinal = ProductCardDiscountMapper.calculateFinalPriceRecommended(product);
// quantityLabel fixo "1" — RF-07.1
// ...
}

Testing Strategy

Testes Unitários — ProductCardDiscountMapper

CenárioEntradaResultado esperadoRF
Sem descontodiscount=0, discountValue=0"R$ 0,00", cor neutralDark, sem -RF-04.1
Desconto percentualdiscount=1, discountValue=10, fullPrice=100"- R$ 10,00", cor successMediumRF-04.2
Desconto valordiscount=2, discountValue=159.90"- R$ 159,90", cor successMediumRF-04.3
Desconto zero calculadodiscountValue=0 com tipo ativo"R$ 0,00", sem -RF-04.4
Sale com diferença positivafullPrice=359.90, price=279.90, discount=0"- R$ 80,00", tipo saleRF-04.5, RF-07.4
discountOrigin=NonediscountOrigin: "None"DiscountType.noneRF-05.1
discountOrigin=MarkdowndiscountOrigin: "Markdown"DiscountType.saleRF-05.2
discountOrigin=Manual + employeeManual, hasEmployeeDiscount: trueDiscountType.funcionarioRF-05.4
discountOrigin=Both + employeeBoth, hasEmployeeDiscount: trueDiscountType.funcionarioRF-05.6
Recommended — manualJSON do RF-07 (exemplo 1)DiscountType.manual, desconto - R$ 159,90RF-07.3
Recommended — saleJSON do RF-07 (exemplo 2)DiscountType.sale, desconto - R$ 80,00RF-07.4
Recommended — funcionárioJSON do RF-07 (exemplo 3)DiscountType.funcionario, - R$ 95,96RF-07.2
Recommended — preço final %discount=1, discountValue=10, fullPrice=100precoFinal = 90RF-08.3
Recommended — preço final value/nonediscount=0 ou 2precoFinal = priceRF-08.2, RF-08.4

Testes Unitários — ProductCartDetail.fromJson

CenárioEntradaResultado esperadoRF
JSON completo (cart-detail.json)Todos os campos novos presentesCampos mapeados corretamenteRF-10.1
Campos novos ausentesJSON sem discountOrigin, etc.Defaults: none, false, falseRF-10.3
Roundtrip toJson/fromJsonObjeto com novos camposValores preservadosRF-10

Testes de Widget (opcional, baixa prioridade)

CenárioVerificaçãoRF
fromRecommendation: trueBanner "Item recomendado adicionado ao pedido" visívelRF-09.1
fromRecommendation: falseBanner ausenteRF-09.2
Card sem onTapNenhum GestureDetector com callbackRF-11.1

Dependências

DependênciaStatusNota
API Cart Detail (discountOrigin, fromRecommendation, hasEmployeeDiscount)DisponívelVer cart-detail.json
Figma ZZAPP 2.0DisponívelLayout do card e tag de recomendado
CartStatusProductCard (legado)ExistenteMantido sem alteração
ZzCard, ZZTextNew, ZzUrlImage, ZZColorsExistenteDesign system atual
MaskUtils.formatNumberBrExistenteFormatação monetária BR
Task 196865 (bloco Recomendados)PendenteConsumirá factory recommended

Rastreabilidade Requisitos → Design

RFElemento do Design
RF-01CartStatusProductCardNew, factories, troca em _ContentProducts
RF-02Layout do widget, remoção de "Remarcação"
RF-03Enum DiscountType + extensão de label
RF-04ProductCardDiscountMapper.formatDiscountDisplay
RF-05mapDiscountTypeFromOrderItem
RF-06ProductCardViewModel.fromOrderItem (valor, total, qtd, tam)
RF-07mapDiscountTypeFromRecommended
RF-08calculateFinalPriceRecommended
RF-09ZzConfirmationBanner, prop fromRecommendation
RF-10Campos novos em ProductCartDetail
RF-11Sem onTap; fetch/refresh inalterados

Checklist de Qualidade

  • Todos os RFs cobertos (RF-01 a RF-11)
  • Tratamento de cenários de borda documentado (defaults, dados incompletos)
  • Segue padrão existente do projeto (models com fromJson, widgets em cart_status/widgets, enums em shared/enum)
  • Componente legado preservado sem alterações
  • Factory recommended preparada para task 196865 sem integrar o bloco
  • Lógica de desconto extraída para testes unitários puros