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.find → FutureBuilder → _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ção | Prós | Contras |
|---|---|---|
A — Refatorar CartStatusProductCard in-place | Menos arquivos | Viola RF-01.4; risco em outras telas |
| B — Novo widget + legado intacto | Escopo isolado; validação gradual | Duplicaçã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ção | Prós | Contras |
|---|---|---|
A — Lógica inline no build() | Implementação rápida | Difícil de testar; build() inchado |
B — Mapper puro + ProductCardViewModel | Testável; widget só renderiza | Um arquivo a mais |
C — Estender ProductCartDetail com getters de display | Dados e display acoplados | Polui 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.
3. Model CartItemRecommendation para factory recommended
Contexto: A API retorna cartItemRecommendations com estrutura diferente de productCartDetails (sem quantity, total, discountOrigin, fromRecommendation).
Opções consideradas:
| Opção | Prós | Contras |
|---|---|---|
A — Reutilizar ProductCartDetail com defaults | Um model só | Campos obrigatórios sem sentido (total, quantity) |
B — Criar CartItemRecommendation | Tipagem correta; prepara task 196865 | Arquivo 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ção | Prós | Contras |
|---|---|---|
A — Reutilizar ZzTag com ZzTagTheme.success | Componente existente | Layout pill ≠ faixa full-width do Figma |
B — _RecommendedAddedBanner privado no card | Fiel ao Figma; escopo local | Não reutilizável fora do card |
C — ZzConfirmationBanner em theme_widgets/banner/ | Reutilizável; fiel ao Figma; alinhado a BannerWidget / ZzTag | Arquivo 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
| Camada | Componente | Tipo | Responsabilidade |
|---|---|---|---|
| Screen | CartStatusDetailScreen | Modificado | Mantém fetch AS-IS; troca widget em _ContentProducts (RF-11) |
| Widget | CartStatusProductCardNew | Novo | Layout Figma; factories orderItem / recommended; consome ZzConfirmationBanner (RF-09) |
| Design System | ZzConfirmationBanner | Novo | Faixa full-width reutilizável com ícone de confirmação e tema success |
| Utils | ProductCardDiscountMapper | Novo | Mapeamento DiscountType, formatação de valores, preço final (RF-04–08) |
| View Model | ProductCardViewModel | Novo | DTO imutável com campos prontos para renderização |
| Model | ProductCartDetail | Modificado | Novos campos API: discountOrigin, fromRecommendation, hasEmployeeDiscount (RF-10) |
| Model | CartItemRecommendation | Novo | Deserialização de cartItemRecommendations para factory recommended |
| Enum | DiscountType | Novo | funcionario, manual, sale, none (RF-03) |
| Enum | CartItemDiscountOrigin | Novo | None, Markdown, Manual, Both (RF-05, RF-10) |
| Legado | CartStatusProductCard | Existente (sem alteração) | Continua em demais telas (RF-01.4) |
Components and Interfaces
Novos Arquivos
| Arquivo | Camada | Tipo |
|---|---|---|
lib/theme_widgets/banner/zz_confirmation_banner.dart | Design System | Faixa de confirmação reutilizável (RF-09) |
lib/screens/cart_status/widgets/cart_status_product_card_new.dart | Widget | StatelessWidget com factories |
lib/screens/cart_status/utils/product_card_discount_mapper.dart | Utils | Funções puras de mapeamento |
lib/screens/cart_status/models/product_card_view_model.dart | View Model | DTO de apresentação |
lib/shared/enum/discount_type.dart | Enum | DiscountType + extensão de label |
lib/shared/enum/cart_item_discount_origin.dart | Enum | CartItemDiscountOrigin + parser fromJson |
lib/models/cart/cart_item_recommendation.dart | Model | Item de cartItemRecommendations |
test/screens/cart_status/utils/product_card_discount_mapper_test.dart | Test | Cobertura RF-04 a RF-08 |
test/models/cart/cart_item_recommendation_test.dart | Test | Deserialização do model |
Arquivos Modificados
| Arquivo | Modificação |
|---|---|
lib/models/product/product_cart_detail.dart | Adicionar discountOrigin, fromRecommendation, hasEmployeeDiscount; defaults seguros no fromJson (RF-10) |
lib/screens/cart_status/cart_status_detail_screen.dart | Em _ContentProducts, trocar CartStatusProductCard por CartStatusProductCardNew.orderItem (RF-01.3) |
lib/theme_widgets/coezzion_design_flutter.dart | Exportar ZzConfirmationBanner |
test/models/product/product_cart_detail_test.dart | Testes 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 (discountOrigin → DiscountType)
discountOrigin | hasEmployeeDiscount | DiscountType |
|---|---|---|
None | — | none |
Markdown | — | sale |
Manual | false | manual |
Manual | true | funcionario |
Both | false | manual |
Both | true | funcionario |
Para
Markdown/sale, o valor exibido na linha de desconto segue RF-04 (baseado emdiscount+discountValue), nãofullPrice - price.
Error Handling
Tabela de Cenários
| Cenário | Comportamento | RF |
|---|---|---|
Campo discountOrigin ausente no JSON | Default CartItemDiscountOrigin.none | RF-10.3 |
Campo fromRecommendation ausente | Default false; tag não exibida | RF-10.3, RF-09.2 |
Campo hasEmployeeDiscount ausente | Default false | RF-10.3 |
discountOrigin com valor desconhecido | Tratar como none | RF-10.3 |
| Nome, imagem ou SKU vazios | Renderizar campos vazios; layout preservado | RF-01 (borda) |
discountValue zero com discount > 0 | Exibir R$ 0,00 sem prefixo -, cor neutralDark | RF-04.4 |
sale com fullPrice - price ≤ 0 | Exibir R$ 0,00 sem prefixo - | RF-04.4, RF-07.5 |
| Imagem com URL inválida | ZzUrlImage 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ário | Entrada | Resultado esperado | RF |
|---|---|---|---|
| Sem desconto | discount=0, discountValue=0 | "R$ 0,00", cor neutralDark, sem - | RF-04.1 |
| Desconto percentual | discount=1, discountValue=10, fullPrice=100 | "- R$ 10,00", cor successMedium | RF-04.2 |
| Desconto valor | discount=2, discountValue=159.90 | "- R$ 159,90", cor successMedium | RF-04.3 |
| Desconto zero calculado | discountValue=0 com tipo ativo | "R$ 0,00", sem - | RF-04.4 |
| Sale com diferença positiva | fullPrice=359.90, price=279.90, discount=0 | "- R$ 80,00", tipo sale | RF-04.5, RF-07.4 |
discountOrigin=None | discountOrigin: "None" | DiscountType.none | RF-05.1 |
discountOrigin=Markdown | discountOrigin: "Markdown" | DiscountType.sale | RF-05.2 |
discountOrigin=Manual + employee | Manual, hasEmployeeDiscount: true | DiscountType.funcionario | RF-05.4 |
discountOrigin=Both + employee | Both, hasEmployeeDiscount: true | DiscountType.funcionario | RF-05.6 |
| Recommended — manual | JSON do RF-07 (exemplo 1) | DiscountType.manual, desconto - R$ 159,90 | RF-07.3 |
| Recommended — sale | JSON do RF-07 (exemplo 2) | DiscountType.sale, desconto - R$ 80,00 | RF-07.4 |
| Recommended — funcionário | JSON do RF-07 (exemplo 3) | DiscountType.funcionario, - R$ 95,96 | RF-07.2 |
| Recommended — preço final % | discount=1, discountValue=10, fullPrice=100 | precoFinal = 90 | RF-08.3 |
| Recommended — preço final value/none | discount=0 ou 2 | precoFinal = price | RF-08.2, RF-08.4 |
Testes Unitários — ProductCartDetail.fromJson
| Cenário | Entrada | Resultado esperado | RF |
|---|---|---|---|
| JSON completo (cart-detail.json) | Todos os campos novos presentes | Campos mapeados corretamente | RF-10.1 |
| Campos novos ausentes | JSON sem discountOrigin, etc. | Defaults: none, false, false | RF-10.3 |
| Roundtrip toJson/fromJson | Objeto com novos campos | Valores preservados | RF-10 |
Testes de Widget (opcional, baixa prioridade)
| Cenário | Verificação | RF |
|---|---|---|
fromRecommendation: true | Banner "Item recomendado adicionado ao pedido" visível | RF-09.1 |
fromRecommendation: false | Banner ausente | RF-09.2 |
Card sem onTap | Nenhum GestureDetector com callback | RF-11.1 |
Dependências
| Dependência | Status | Nota |
|---|---|---|
API Cart Detail (discountOrigin, fromRecommendation, hasEmployeeDiscount) | Disponível | Ver cart-detail.json |
| Figma ZZAPP 2.0 | Disponível | Layout do card e tag de recomendado |
CartStatusProductCard (legado) | Existente | Mantido sem alteração |
ZzCard, ZZTextNew, ZzUrlImage, ZZColors | Existente | Design system atual |
MaskUtils.formatNumberBr | Existente | Formatação monetária BR |
| Task 196865 (bloco Recomendados) | Pendente | Consumirá factory recommended |
Rastreabilidade Requisitos → Design
| RF | Elemento do Design |
|---|---|
| RF-01 | CartStatusProductCardNew, factories, troca em _ContentProducts |
| RF-02 | Layout do widget, remoção de "Remarcação" |
| RF-03 | Enum DiscountType + extensão de label |
| RF-04 | ProductCardDiscountMapper.formatDiscountDisplay |
| RF-05 | mapDiscountTypeFromOrderItem |
| RF-06 | ProductCardViewModel.fromOrderItem (valor, total, qtd, tam) |
| RF-07 | mapDiscountTypeFromRecommended |
| RF-08 | calculateFinalPriceRecommended |
| RF-09 | ZzConfirmationBanner, prop fromRecommendation |
| RF-10 | Campos novos em ProductCartDetail |
| RF-11 | Sem 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 emcart_status/widgets, enums emshared/enum) - Componente legado preservado sem alterações
- Factory
recommendedpreparada para task 196865 sem integrar o bloco - Lógica de desconto extraída para testes unitários puros