Design Document — US 197021 (PR2: coezzion_vendas_app)
Requisitos: 197021-requisitos.md
Pesquisa: design-research-zzapp.md
Design API (contrato): 197021-design-showcase-api.md
RFs cobertos: RF-01, RF-02, RF-03, RF-04, RF-05, RF-08, RF-11
Overview
O PR2 adapta o zzapp para que o vendedor possa criar e editar vitrines Minhas e Histórico com prazo padrão (+31 dias) ou sem data de expiração (clear no picker → dateExpiration: null no payload). A API (PR1) persiste never-expire; o app apenas exibe "Sem data de expiração" quando a resposta traz null.
Fluxo Flutter: Screen → Controller (GetX) → Model → ApiUtils/Provider. Mudanças concentram-se em ZZDateFormField, models nullable, remoção de fallbacks +31 no update e labels na listagem Minhas.
Contexto
Recommend list e Brand list já usam nullExpirationLabel. Minhas quebra parse com asDateTimeOrThrow e esconde campo no detail quando null. Recommend create/edit bloqueia submit sem data via toast.
Decisões de Design
| ID | Decisão | Alternativa rejeitada | Racional | RF |
|---|---|---|---|---|
| D-APP-1 | Estender ZZDateFormField com allowClear + onClear + label parametrizável quando null | Widget só em show_case | Reuso; copy não hardcoded no widget (D-APP-9) | RF-01, RF-03 |
| D-APP-2 | Default maxDaysFromNow no controller na abertura (D-APP-14) | Esperar default da API no create | D4: default é UX, não server | RF-02 |
| D-APP-3 | Chave dateExpiration sempre no JSON; valor null quando sem prazo (D-APP-10) | Omitir chave | Contrato explícito com API PR1 | RF-02, RF-03 |
| D-APP-4 | Remover fallbacks ?? now+31 no update Minhas | Manter fallback client | Conflita com clear→never | RF-03 |
| D-APP-5 | nullExpirationLabel na lista Minhas | Linha vazia sem expiração | CA-4 / padrão Recommend | RF-04 |
| D-APP-6 | Detail Minhas sempre mostra picker | Ocultar se null | Usuário precisa adicionar prazo depois | RF-03, RF-04 |
| D-APP-7 | Recommend edit: min amanhã + max +31 (D-APP-12) | Manter min hoje no edit | Paridade create/edit e API | RF-08 |
| D-APP-8 | Minhas edit: _initialExpirationDate + hasExpirationDateChanged (padrão Histórico) | Corrigir só enableEditButton | AS-IS não detecta clear→null; Histórico já tem _isSameDate | RF-03, RF-04 |
| D-APP-9 | Clear UX: texto legível parametrizável no campo + ícone X | Campo vazio com hint genérico | Paridade portal; caller passa ex. 'Sem data de expiração' | RF-04 |
| D-APP-10 | JSON: chave dateExpiration sempre presente; valor null literal | Omitir chave | Contrato explícito PR1 | RF-02, RF-03 |
| D-APP-11 | Fallback +31 removido em detalhe e busca de produtos Minhas | Só detalhe | Dois PUT paths no AS-IS | RF-03 |
| D-APP-12 | Histórico edit: firstExpirationDate = amanhã (como create) | Manter min hoje | Assimetria AS-IS; API exige futura | RF-08 |
| D-APP-13 | Deploy simultâneo API + app | API primeiro com app antigo | App novo + API antiga quebra clear | RF-11 |
| D-APP-14 | ShowCaseExpirationDefaults.maxDaysFromNow = 31 centralizado | Magic numbers inline | Alinha API; facilita PO 30↔31 | RF-02, RF-08 |
| D-APP-15 | Testes: models obrigatórios; controllers se baixo acoplamento | Widget tests / controllers forçados | Autoclosure GetX pode inviabilizar unit de controller | RF-11 |
| D-APP-16 | loadData espelha dateExpiration da API em expirationDate + _initialExpirationDate | Fallback no PUT ?? detail | Evita PUT de nome-only virar never-expire | RF-03 |
Architecture
Diagrama de Fluxo
Camadas e Responsabilidades
| Camada | Componente | Tipo | Responsabilidade | RF |
|---|---|---|---|---|
| Widget | zz_date_form_field.dart | Modificado | Clearable | RF-01 |
| Minhas create/edit busca | show_case_search_products_controller.dart | Modificado | Default + clear; PUT sem ?? +31; setEditMode(DateTime?) | RF-02, RF-03, D-APP-11 |
| Minhas create | check_fast_catalog_product_list.dart | Modificado | allowClear | RF-02 |
| Minhas edit | show_case_detail_screen.dart | Modificado | Campo sempre visível | RF-03, RF-04 |
| Minhas edit | show_case_detail_controller.dart | Modificado | D-APP-16 sync loadData; hasExpirationDateChanged; sem fallback +31 | RF-03 |
| Minhas list | my_show_case_list_screen.dart | Modificado | nullExpirationLabel | RF-04 |
| Recommend create | recommend_show_case_check_products_* | Modificado | Sem toast obrigatório | RF-01, RF-02 |
| Recommend edit | recommend_show_case_detail_* | Modificado | Clear + max picker | RF-03, RF-08 |
| Models | update_show_case_request.dart | Modificado | DateTime? | RF-03 |
| Models | show_case_response_item.dart | Modificado | Parse nullable | RF-04 |
| Tests | models show_case + recommend | Modificado | Serialização null | RF-11 |
Components and Interfaces
Arquivos modificados (inventário)
Detalhe em design-research-zzapp.md.
Interface ZZDateFormField (proposta)
class ZZDateFormField extends StatefulWidget {
final DateTime? selectedDate;
final bool allowClear; // default: false — RF-01
final VoidCallback? onClear; // RF-03
final String? emptyDateLabel; // D-APP-9: texto no campo quando selectedDate == null
final void Function(DateTime) onChanged;
// hint permanece para fluxos sem emptyDateLabel (ex.: férias, CRM)
}
Comportamento (D-APP-9):
- Com data: exibe
dd/MM/yyyy+ ícone X no suffix (seallowClear) - Após clear (
selectedDate == null): exibeemptyDateLabeldo caller (ex. vitrines:'Sem data de expiração'), não o hintdd/mm/aaaa - Sem
emptyDateLabel: comportamento legado (campo vazio + hint) - Vitrines Minhas + Histórico:
allowClear: true,emptyDateLabel: ShowCaseExpirationDefaults.noExpirationLabel - Create: default +31 pré-preenchido; clear opcional antes de salvar
Data Models
ShowCaseExpirationDefaults (D-APP-14)
abstract final class ShowCaseExpirationDefaults {
static const int maxDaysFromNow = 31; // D3: alinhar com PO
static const String noExpirationLabel = 'Sem data de expiração';
}
Usar em: default create, initialDateRange/finalDateRange, nullExpirationLabel na lista Minhas, emptyDateLabel do picker.
UpdateShowCaseRequest (RF-03)
class UpdateShowCaseRequest {
final DateTime? dateExpiration; // era DateTime required
Map<String, dynamic> toJson() => {
// ...
// D-APP-10: chave sempre presente; null literal no Map → "dateExpiration":null no JSON
'dateExpiration': dateExpiration?.toIso8601String(),
};
}
ShowCaseResponseItem (RF-04)
class ShowCaseResponseItem {
final DateTime? expirationDate; // era required
factory ShowCaseResponseItem.fromJson(Map<String, dynamic> json) {
return ShowCaseResponseItem(
expirationDate: picker('dateExpiration').asDateTimeOrNull(),
);
}
}
Submit Minhas update (RF-03 — sem fallback, D-APP-8)
// D-APP-16: loadData — após detail.value = result:
expirationDate.value = result.dateExpiration;
_initialExpirationDate = result.dateExpiration;
bool get hasExpirationDateChanged =>
!_isSameDate(expirationDate.value, _initialExpirationDate);
bool get enableEditButton =>
hasNameChanged.value ||
hasProductsChanged.value ||
hasExpirationDateChanged; // inclui clear → null
// PUT — sem fallback:
dateExpiration: expirationDate.value,
Recommend controllers (RF-01)
// ANTES:
if (expirationDate == null) {
ZzToast.showError('Selecione a data de expiração');
return;
}
// DEPOIS: permitir null; enviar no request
Error Handling
| Cenário | Comportamento | RF |
|---|---|---|
| API retorna 400 cap excedido | Toast/erro via fluxo DefaultApiController existente | RF-08 |
| Parse list com null | asDateTimeOrNull + label | RF-04 |
| Usuário limpa e salva | Envia null; sem toast local bloqueando | RF-02 |
| Share/copy never-expire | Fluxo inalterado | RF-05 |
Testing Strategy
Testes unitários — models (obrigatório, D-APP-15)
| Arquivo de teste | Cenário novo | RF |
|---|---|---|
show_case_response_item_test (criar se ausente) | fromJson com dateExpiration: null | RF-04 |
update_show_case_request_test (criar se ausente) | toJson → "dateExpiration":null | RF-03, D-APP-10 |
create_recommend_show_case_request_test | toJson com null | RF-02 |
update_recommend_show_case_request_test | toJson com null | RF-03 |
Testes unitários — controllers (desejável, D-APP-15)
Extrair e testar funções puras quando possível (ex.: _isSameDate, normalização de payload) sem montar GetX completo.
| Cenário | Controller | Se acoplamento alto |
|---|---|---|
hasExpirationDateChanged detecta clear→null | show_case_detail_controller | Cobrir no smoke manual |
PUT sem ?? +31 | show_case_search_products_controller | Cobrir no smoke manual |
| Submit Recommend com null | recommend_show_case_* | Cobrir no smoke manual |
Regra: se o teste exigir mock pesado de GetxController + services, não criar — documentar no PR e reforçar checklist manual abaixo.
Smoke manual
| Cenário | RF |
|---|---|
| Create Minhas sem tocar data → lista com data ~+31 | RF-02 |
| Create Minhas clear → "Sem data de expiração" | RF-04 |
| Edit remove/adiciona data Minhas e Recommend | RF-03 |
| Share link never-expire | RF-05 |
Deploy (D-APP-13)
Release simultâneo API + app no mesmo train. PR1 mergeado e disponível em produção antes do PR2. Sem feature flag nesta US. QA só com par API+app alinhados (RF-11).
Dependências
| Dependência | Status | Nota |
|---|---|---|
| PR1 showcase API | Bloqueante recomendado | Contrato null=never |
| Copy "Sem data de expiração" | Confirmado | Portal + Recommend list |
ZZDateFormField | Disponível | Estender, não substituir |
| Brand flows | Fora escopo | Sem alteração |
Checklist de Qualidade
- RF-01 a RF-04, RF-08, RF-11 cobertos no app
- Nenhum fallback client
?? now+31no update após clear - Lista Minhas não quebra com null da API
- Recommend create/edit aceita null
- Picker max +31 alinhado em create e edit Recommend
- Brand e share inalterados (RF-05)