Skip to main content

Design Document: [Front][App] Ajuste de layout nova vitrine

Overview

Adaptar o fluxo Minhas Vitrines no App Flutter (coezzion_vendas_app) para suportar a modalidade Vitrine de Estoque da Loja (showCaseType = "SellerStock"), coexistindo com a vitrine personalizada atual (showCaseType = "Store").

A task cobre 4 frentes de mudança:

  1. Migração para endpoint v2 da listagem com flags hasSellerStock e hasNext
  2. Bottom sheet de escolha de modalidade ao criar vitrine
  3. Criação da Vitrine de Estoque da Loja sem tela intermediária — a tela de detalhe assume um modo de criação (POST + GET encadeados)
  4. Detalhe condicional ao showCaseType (layout, regras de edição, contagem de produtos, rewards)

Além disso, uma 5ª frente foi incorporada na revisão de 03/07/2026: aviso de alterações não salvas ao sair, visualizar, compartilhar ou copiar link (RF-06).

Nota de revisão (03/07/2026): este documento foi revisado numa sessão de grilling que cruzou cada decisão com o código atual do app. Várias decisões do documento original foram corrigidas ou substituídas — os achados detalhados (com referências de arquivo/linha) estão em context/*.md. Este documento reflete o estado final acordado.


Contexto

AS-IS (Validado em 25/06/2026; atualizado em 03/07/2026)

Nota de revisão (03/07/2026): Este documento foi revisado em uma sessão de grilling que cruzou cada decisão com o código atual do app e identificou várias lacunas, comportamentos não-mapeados e possíveis bugs. Os achados detalhados (com referências de arquivo/linha e roadmap de correção) estão em context/*.md. Este documento reflete o estado final acordado após revisão.

AspectoEstado atual
Botão CRIAR VITRINENavega direto para ShowCaseSearchProducts (fluxo de seleção de produtos). Sem bottom sheet de escolha de modalidade.
Endpoint listagemGET /api/show-case-new/store/{storeId} (v1) retorna items: [] + pagination. Sem hasSellerStock.
DTOs Create/UpdateCreateShowCaseRequest e UpdateShowCaseRequest não incluem showCaseType.
DetalheShowCaseDetailResponse não inclui showCaseType nem totalItems. Contagem sempre via showCaseItems.length.
Componente detalheShowCaseDetailLayout renderiza nome editável, grid com até 4 itens, botão ADICIONAR PRODUTOS, ícone de remoção por card. Sem branching por tipo. Heading da seção ("Confira os produtos adicionados") é hardcoded, não parametrizado.
Enum ShowCaseTypeNão existe; valores hardcoded em testes/mocks (magic numbers 1, 4).

Lacunas (TO-BE)

  1. Bottom sheet "Escolha uma opção" ao clicar em CRIAR VITRINE (RF-02)
  2. Parsing de hasSellerStock/hasNext do endpoint v2; bloqueio de criação SellerStock se já existe (RF-01)
  3. Criação da Vitrine de Estoque da Loja sem tela intermediária: tela de detalhe ganha modo de criação (POST + GET encadeados) (RF-03)
  4. Detalhe adaptado: nome fixo "Estoque da loja", contagem via totalItems, sem edição/link "Todos os produtos", PUT só efetiva dateExpiration, sem reward events nativos (RF-04)
  5. Suporte aos novos campos em DTOs: showCaseType obrigatório em requests, totalItems/showCaseType em response de detalhe, hasNext/hasSellerStock na listagem v2 (RF-05)
  6. Aviso de alterações não salvas ao sair, visualizar, compartilhar ou copiar link — hoje só existe (parcialmente) para "sair"; falta para as outras 3 ações (RF-06)

Decisões de Design

1. Bottom Sheet de Escolha de Modalidade

Decisão: Novo widget ShowCaseTypeSelectionBottomSheet invocado via GetxBottomSheet.showBottomSheet(), seguindo padrão já estabelecido em ShowCaseBottomSheet.

Justificativa:

  • Reutiliza infraestrutura existente (GetxBottomSheet) → menos código, comportamento consistente.
  • Widget independente → testável e reutilizável.
  • Centraliza lógica de visibilidade/disable da opção SellerStock (condicionada a hasSellerStock).

Implementação:

  • createFastCatalog() no MyShowCaseController passa a chamar showBottomSheet em vez de navegar direto.
  • Bottom sheet recebe hasSellerStock como prop (valor síncrono no momento da abertura — não precisa ser reativo, já que é uma UI efêmera).
  • Opção "Vitrine do estoque da loja" renderizada com disable visual (opacidade, sem tap) se hasSellerStock=true.
  • Mensagem "Você já possui uma Vitrine de Estoque ativa..." exibida abaixo do título quando desabilitada.
  • Opção "Vitrine do estoque da loja" habilitada (hasSellerStock=false) chama MyShowCaseController.createSellerStock() (ver Decisão #3) — não navega para nenhuma tela de formulário.
  • Badge "Nova" da opção usa ZzTag(theme: ZzTagTheme.newFeature, text: 'Nova')novo valor de enum em ZzTagTheme (theme_widgets/tag/tag.dart), já que os temas existentes (primary, red, yellow, success) não têm a combinação de cores pedida pelo Figma (fundo sólido, não o tom pastel usado hoje pelo badge "Nova" de vitrines de marca). newFeature: backgroundColor: ZZColors.successMedium, foregroundColor: ZZColors.neutralLightest. (Nome newFeature em vez de newnew é palavra reservada em Dart, não pode ser identificador.)
// lib/theme_widgets/tag/tag.dart (DIFF)
enum ZzTagTheme { primary, red, yellow, success, newFeature }

extension ZzTagColorExtension on ZzTagTheme {
Color get backgroundColor {
switch (this) {
// ... casos existentes ...
case ZzTagTheme.newFeature:
return ZZColors.successMedium;
}
}

Color get foregroundColor {
switch (this) {
// ... casos existentes ...
case ZzTagTheme.newFeature:
return ZZColors.neutralLightest;
}
}
}

2. Endpoint v2 e Flags hasSellerStock / hasNext

Decisão: Nova URL getStoreShowCaseV2Url(...) em ApiUtils. Parse manual do response v2 em MyShowCaseController._load() em vez de delegar para DefaultApiController.findPagedList(). ApiUtils.getStoreShowCaseUrl (v1) é removido — fica sem nenhum outro chamador no app após a migração.

Justificativa:

  • v2 muda estrutura de paginação: itemsdata, sem objeto pagination aninhado (estrutura plana).
  • findPagedList() espera response['pagination'] (aninhado) — incompatível com o formato plano da v2. Parse manual é necessário, não apenas preferência de estilo.
  • Parse manual evita quebrar outros fluxos que ainda usam DefaultApiController com v1.
  • hasSellerStock armazenado como RxBool no controller → observável, dispara rebuild do bottom sheet.
  • hasNext é exposto diretamente pelo backend na v2 (confirmado) — o App não precisa calcular a partir de total/pageNumber/pageSize.

Implementação:

  • ApiUtils.getStoreShowCaseV2Url(storeId, pageNumber, search, pageSize=20) retorna URL com caminho /v2/.
  • MyShowCaseController._load() chama URL v2, faz parse manual de ShowCaseListV2Response, extrai data, hasNext e hasSellerStock.
  • hasSellerStock atualizado em cada página carregada (útil se vendedor cria vitrine em outra aba e volta).
  • ApiUtils.getStoreShowCaseUrl (v1) removido do ApiUtils.

3. Criação da Vitrine de Estoque da Loja — Sem Tela Intermediária

Decisão: Não existe tela/controller/rota nova de criação. Ao tocar em "Vitrine do estoque da loja" no bottom sheet (com hasSellerStock == false), o app navega diretamente para ShowCaseDetailScreen (rota já existente, AppRoutes.storeShowCaseDetail), em um novo modo de criação. A própria tela/controller de detalhe assume a responsabilidade de criar a vitrine (POST) e, em seguida, carregar seus dados completos (GET) — usando a infraestrutura de loading/erro que já existe no app.

Justificativa:

  • O único dado variável na criação é a data de expiração — e ela já é editável na tela de detalhe existente (ATUALIZAR VITRINE, RF-04 AC10/11). Não há necessidade de uma tela extra só para confirmar isso antes de criar; o vendedor pode ajustar a data depois de ver a vitrine criada.
  • Elimina uma tela, um controller e uma rota inteiros (ShowCaseSellerStockScreen, ShowCaseSellerStockController, AppRoutes.showCaseSellerStock), reduzindo esforço de implementação e manutenção.
  • Reaproveita 100% a UI de loading e erro já usada no app (skeleton de ShowCaseDetailLayout; estado de erro no padrão do UpsellRecommendationScreen).
  • POST /api/show-case-new retorna apenas {id, url} (CreateShowCaseResponse) — nunca o shape completo de ShowCaseDetailResponse. Um GET subsequente é inevitável para preencher a tela; encadear os dois dentro do controller de detalhe é o caminho mais direto.
  • Contrato atualizado: a API não retorna mais erro quando já existe uma vitrine SellerStock ativa para (userId, storeId) — o POST retorna 200 OK com os dados da vitrine existente ("get-or-create"). Do ponto de vista do App, não há distinção entre "criada agora" e "já existia"; o único caminho de erro tratado é falha inesperada (rede, 5xx).
  • Nenhum reward event é registrado nesse fluxo (nem na criação, nem nas ações de visualizar/compartilhar/copiar do detalhe) enquanto a task 197192 (eventos específicos de Rewards para SellerStock) estiver pendente — evita rotular incorretamente ações de SellerStock com eventos "Store".

Implementação:

ShowCaseDetailScreenArguments ganha dois campos novos (catalogId passa a ser opcional; isCreating novo, default false — não quebra o único call site existente, goToDetailFastCatalog()):

class ShowCaseDetailScreenArguments {
final String catalogName;
final String? catalogId;
final bool isCreating;

ShowCaseDetailScreenArguments({
required this.catalogName,
this.catalogId,
this.isCreating = false,
});
}

MyShowCaseController ganha um novo método, chamado pelo callback do bottom sheet:

Future<void> createSellerStock() async {
final args = ShowCaseDetailScreenArguments(
catalogName: 'Estoque da loja',
isCreating: true,
);

await Get.toNamed(AppRoutes.storeShowCaseDetail, arguments: args);

// mesmo padrão de goToDetailFastCatalog(): recarrega a listagem ao voltar
// (mostra a vitrine recém-criada e atualiza hasSellerStock)
_hasNextPage = true;
pagingController.value = PagingState();
}

Nenhum reward event de criação é registrado (MyShowCaseController não precisa ganhar RewardEventController como dependência nova por causa disso).

ShowCaseDetailController ramifica por arguments.isCreating no onInit():

final isLoading = false.obs;
final hasError = false.obs;


void onInit() {
arguments = Get.arguments as ShowCaseDetailScreenArguments;
nameController.text = arguments.catalogName;
nameController.addListener(() {
hasNameChanged.value = nameController.text != detail.value?.name;
update();
});
expirationDate.listen((nd) => update());

if (arguments.isCreating) {
createAndLoad();
} else {
loadData(arguments);
}

super.onInit();
}

Future<void> createAndLoad() async {
isLoading.value = true;
hasError.value = false;
update();

try {
final request = await _buildSellerStockCreateRequest();
// name: 'Estoque da loja', showCaseType: ShowCaseType.sellerStock,
// showCaseItems: [], dateExpiration: hoje + 30 dias (limite desta modalidade), + demais campos
// obrigatórios (schema, storeId, storeName, maxInstallments, userId,
// userName, userPhone, codeBrand) — mesmo padrão de postFastCatalog().

final response = await post(
request.toJson(),
customUrl: await ApiUtils.postShowCaseUrl(),
showLoading: false, // loading próprio via isLoading/skeleton
);
final created = CreateShowCaseResponse.fromJson(response);

// POST só retorna {id, url} — encadeia com o GET normal para
// popular a tela por completo (showCaseType, totalItems, preview, etc.)
arguments = ShowCaseDetailScreenArguments(
catalogName: arguments.catalogName,
catalogId: created.id,
);

await loadData(arguments);
} catch (e) {
hasError.value = true;
} finally {
isLoading.value = false;
update();
}
}

/// Chamado pelo botão "TENTE NOVAMENTE" do estado de erro.
void retryCreate() => createAndLoad();

ShowCaseDetailScreen passa a checar controller.hasError.value (antes de isLoading), renderizando um ZzErrorState — mesma dinâmica do UpsellRecommendationScreen (title, description genéricos, retryText: 'TENTE NOVAMENTE', onRetry: controller.retryCreate) — no lugar do ShowCaseDetailLayout enquanto hasError == true. Mensagem sempre genérica, sem diferenciar tipo de erro (a "duplicata" deixou de ser um erro, ver acima).


4. Detalhe Condicional por showCaseType

Decisão: Branching no ShowCaseDetailController (lógica) e ShowCaseDetailScreen (renderização). Parametrização do ShowCaseDetailLayout com props opcionais.

Justificativa:

  • Evita duplicação de tela (uma única ShowCaseDetailScreen, não duas variantes).
  • ShowCaseDetailLayout já é compartilhado por todas as variantes de detalhe (brand, recommend, store) → estender é natural.
  • Controller lê showCaseType e aplica regras: PUT só dateExpiration para type=4, contagem via totalItems, etc.

Implementação:

  • ShowCaseDetailController.loadData()detail.value!.showCaseType após GET.
  • showCaseType == ShowCaseType.store (Store): comportamento atual, sem mudança (exceto a correção de enableEditButton, ver abaixo, que se aplica aos dois tipos).
  • showCaseType == ShowCaseType.sellerStock (SellerStock):
    • name marcado como read-only (não editável via form).
    • totalItems usado para contagem em vez de showCaseItems.length.
    • Botão ADICIONAR PRODUTOS ocultado.
    • Ícones de remoção de produto ocultados.
    • Sem link "Todos os produtos", mesmo quando totalItems > 4 — não existe endpoint para carregar a lista completa por vitrine (só o preview de 4 itens vem no GET de detalhe); a grid sempre mostra no máximo os 4 itens do preview.
    • updateShowCase() envia PUT com showCaseType: ShowCaseType.sellerStock, forçando showCaseItems: [] explicitamente (mesmo a API ignorando esse campo, deixa claro no código que os productIds do preview não representam a vitrine real).
    • Nenhum reward event é registrado em viewShowCase()/shareShowCase()/copyLink() quando showCaseType == ShowCaseType.sellerStock (chamadas a rewardEventController.register(...) condicionadas a showCaseType == ShowCaseType.store).
  • ShowCaseDetailScreen passa props ao ShowCaseDetailLayout:
    • hideProductActions = (showCaseType == ShowCaseType.sellerStock) → remove botão ADICIONAR e ícones de remove.
    • sectionTitle = (showCaseType == ShowCaseType.sellerStock) ? "Confira os produtos" : "Confira os produtos adicionados" (novo parâmetro — o heading é hoje hardcoded no layout, não parametrizado).
    • productCountText = (showCaseType == ShowCaseType.sellerStock) ? "${detail.totalItems} produtos recomendados" : "${items.length} produtos adicionados" (parâmetro já existente, só muda o cálculo).
    • productCount = items.length > 4 ? 4 : items.lengthsempre, independente do showCaseType. Nunca usar detail.totalItems aqui: productCount alimenta o itemCount do GridView.builder, cujo itemBuilder indexa a lista local items (no máximo 4 elementos para SellerStock); usar totalItems diretamente causaria RangeError sempre que totalItems > 4.
    • onViewAllProducts = null quando showCaseType == ShowCaseType.sellerStock (sem link "Todos os produtos" para esta modalidade).
    • appBarTitle = (showCaseType == ShowCaseType.sellerStock) ? "Estoque da loja" : catalogName. Como o backend garante name = "Estoque da loja" já na listagem v2 para vitrines SellerStock, arguments.catalogName já nasce correto antes do GET de detalhe resolver — sem risco de "flash" de texto incorreto.
  • ShowCaseDetailLayout recebe novos params opcionais: hideProductActions (bool, default false), sectionTitle (String, default 'Confira os produtos adicionados', substituindo o ZZTextNew hardcoded) — sem quebrar assinaturas existentes (productCountText/productCount já eram obrigatórios, não são novos).

Limite de data de expiração condicionado ao tipo: o campo ZZDateFormField em _HeaderContent (show_case_detail_screen.dart) hoje tem finalDateRange fixo em DateTime.now().add(const Duration(days: 31)) para todos os tipos. SellerStock tem um limite de negócio diferente — 30 dias, não 31 — conforme Figma (context/image-3.png) e US 196053 (CA-3). Passa a ser condicional:

finalDateRange: (detail.value?.showCaseType == ShowCaseType.sellerStock)
? DateTime.now().add(const Duration(days: 30))
: DateTime.now().add(const Duration(days: 31)),

O mesmo limite de 30 dias vale para o dateExpiration inicial enviado no POST de criação (Decisão #3).

Correção de enableEditButton (aplicada a Store e SellerStock):

Comportamento atual: expirationDate.value != null habilita o botão só por o vendedor ter tocado no campo de data, mesmo escolhendo a mesma data já salva — não compara com o valor original. Corrigido para os dois tipos:

bool get enableEditButton {
final currentDetail = detail.value;
if (currentDetail == null) return false;

final dateChanged = expirationDate.value != null &&
expirationDate.value != currentDetail.dateExpiration;

final baseCondition = hasNameChanged.value || hasProductsChanged.value || dateChanged;

if (currentDetail.showCaseType == ShowCaseType.sellerStock) {
// Sem exigir showCaseItems não-vazio: uma loja com estoque zerado
// ainda deve poder atualizar a data de expiração.
return baseCondition;
}

return baseCondition && currentDetail.showCaseItems.isNotEmpty;
}

5. Enum ShowCaseType

Decisão: Novo enum ShowCaseType em lib/shared/enum/show_case_type.dart, espelhando o enum C# da API, com serialização string via extension + parser (mesmo padrão de cart_item_discount_origin.dart).

Valores (C# → JSON → Dart):

C#JSON (API)Dart
Brand"Brand"ShowCaseType.brand
Store"Store"ShowCaseType.store
Recommendation"Recommendation"ShowCaseType.recommendation
BrandSeller"BrandSeller"ShowCaseType.brandSeller
SellerStock"SellerStock"ShowCaseType.sellerStock

Justificativa:

  • A API serializa o enum C# como string PascalCase, não como inteiro.
  • Elimina magic strings/numbers espalhados no código.
  • Type-safe: comparações via showCaseType == ShowCaseType.sellerStock.
  • Padrão já consolidado no app (CartItemDiscountOrigin).

Implementação:

// lib/shared/enum/show_case_type.dart
enum ShowCaseType { brand, store, recommendation, brandSeller, sellerStock }

extension ShowCaseTypeApi on ShowCaseType {
String toApiString() {
switch (this) {
case ShowCaseType.brand: return 'Brand';
case ShowCaseType.store: return 'Store';
case ShowCaseType.recommendation: return 'Recommendation';
case ShowCaseType.brandSeller: return 'BrandSeller';
case ShowCaseType.sellerStock: return 'SellerStock';
}
}
}

abstract class ShowCaseTypeParser {
static ShowCaseType fromJson(String? value) {
switch (value) {
case 'Brand': return ShowCaseType.brand;
case 'Store': return ShowCaseType.store;
case 'Recommendation': return ShowCaseType.recommendation;
case 'BrandSeller': return ShowCaseType.brandSeller;
case 'SellerStock': return ShowCaseType.sellerStock;
default: return ShowCaseType.store; // fallback seguro
}
}
}

Nota de revisão: a subtask 01 implementou inicialmente store(1)/sellerStock(4) com fromValue(int). Corrigido na subtask 01b.


6. DTOs — Suporte a Novos Campos

Decisão: Adicionar showCaseType: ShowCaseType obrigatório (sem default) em CreateShowCaseRequest, UpdateShowCaseRequest. Adicionar showCaseType/totalItems em ShowCaseDetailResponse (com default para retrocompatibilidade, já que vêm de um GET que pode ainda não ter os campos). Novo model ShowCaseListV2Response para response paginada v2 (incluindo hasNext).

Justificativa:

  • Requisito RF-05: DTOs devem refletir contrato da API (string enum, não inteiro).
  • showCaseType obrigatório (não default store): como dois fluxos diferentes constroem CreateShowCaseRequest/UpdateShowCaseRequest (Store via ShowCaseSearchProductsController, SellerStock via ShowCaseDetailController.createAndLoad()), um default silencioso esconderia a intenção em cada call site. ShowCaseSearchProductsController.postFastCatalog()/putFastCatalog() precisam passar showCaseType: ShowCaseType.store explicitamente.
  • ShowCaseDetailResponse/ShowCaseListV2Response: campos com default no fromJson, pois vêm de leitura de API (retrocompatibilidade caso o backend ainda não tenha entregue o campo).

Implementação:

// lib/models/show_case/create_show_case_request.dart
class CreateShowCaseRequest implements DefaultModelInterface {
// ... campos existentes ...
+ final ShowCaseType showCaseType; // obrigatório, sem default

Map<String, dynamic> toJson() => {
// ... existing fields ...
'showCaseType': showCaseType.toApiString(),
};
}

// lib/models/show_case/update_show_case_request.dart
class UpdateShowCaseRequest {
// ... campos existentes ...
+ final ShowCaseType showCaseType; // obrigatório, sem default

Map<String, dynamic> toJson() => {
// ... existing fields ...
'showCaseType': showCaseType.toApiString(),
};
}

// lib/models/show_case/show_case_detail_response.dart
class ShowCaseDetailResponse {
// ... campos existentes ...
+ final ShowCaseType showCaseType; // default store se ausente
+ final int totalItems; // default showCaseItems.length se ausente

factory ShowCaseDetailResponse.fromJson(Map<String, dynamic> json) {
final picker = pick(json);
return ShowCaseDetailResponse(
// ... existing fields ...
showCaseType: ShowCaseTypeParser.fromJson(
picker('showCaseType').asStringOrNull(),
),
totalItems: picker('totalItems').asIntOrNull() ??
(picker('showCaseitems').asList().length),
);
}
}

// lib/models/show_case/show_case_list_v2_response.dart (NOVO)
class ShowCaseListV2Response {
final List<ShowCaseResponseItem> data;
final int total;
final int pageNumber;
final int pageSize;
final bool hasSellerStock;
final bool hasNext; // exposto diretamente pelo backend, sem cálculo no client

ShowCaseListV2Response({
required this.data,
required this.total,
required this.pageNumber,
required this.pageSize,
required this.hasSellerStock,
required this.hasNext,
});

factory ShowCaseListV2Response.fromJson(Map<String, dynamic> json) {
final picker = pick(json);
return ShowCaseListV2Response(
data: picker('data').asListOrEmpty<Map<String, dynamic>>()
.map((e) => ShowCaseResponseItem.fromJson(e))
.toList(),
total: picker('total').asIntOrThrow(),
pageNumber: picker('pageNumber').asIntOrThrow(),
pageSize: picker('pageSize').asIntOrThrow(),
hasSellerStock: picker('hasSellerStock').asBoolOrNull() ?? false,
hasNext: picker('hasNext').asBoolOrNull() ?? false,
);
}
}

Decisão: Um único método privado no ShowCaseDetailController, _confirmProceedWithPendingChanges(String actionDescription), cobre as 4 situações (sair, visualizar, compartilhar, copiar link), reaproveitando o BottomSheetAskLauncher já usado hoje só para "sair".

Justificativa:

  • onWillPop() já implementa esse aviso para "sair da tela" — mas viewShowCase()/shareShowCase()/copyLink() abrem/compartilham/copiam o link salvo no backend, que fica desatualizado se houver alteração pendente (nome, data ou itens) ainda não persistida via updateShowCase().
  • Reaproveitar o mesmo widget (BottomSheetAskLauncher) e o mesmo critério (enableEditButton) evita 4 implementações divergentes.
  • Vale para os dois tipos (Store e SellerStock) — mesmo critério enableEditButton já corrigido na Decisão #4.

Implementação:

/// Retorna true se a ação que motivou a chamada deve prosseguir (sem
/// alterações pendentes, ou o vendedor escolheu prosseguir mesmo assim —
/// com ou sem salvar antes). Retorna false se a modal foi fechada sem
/// escolha, ou se "Salvar" falhou (validação ou erro de API).
Future<bool> _confirmProceedWithPendingChanges(String actionDescription) async {
if (!enableEditButton) return true;

final answer = await BottomSheetAskLauncher.ask(
icon: PhosphorIcons.info,
title: 'A vitrine possui alterações não salvas',
description: 'Deseja salvar as alterações antes de $actionDescription?',
confirmButton: BottomSheetLauncherConfirmButton(title: 'Salvar'),
cancelButton: BottomSheetLauncherCancelButton(title: 'Não salvar'),
theme: BottomSheetLauncherTheme.info,
);

if (answer == null) return false; // fechou sem escolher
if (answer) {
final saved = await updateShowCase();
if (!saved) return false; // falhou ao salvar
}
return true; // "Não salvar" OU salvou com sucesso
}

Os 4 pontos de entrada passam a chamar esse helper no início:

Future<bool> onWillPop() => _confirmProceedWithPendingChanges('sair');

Future<void> viewShowCase() async {
if (!await _confirmProceedWithPendingChanges('visualizar')) return;
// ...corpo atual sem mudança...
}

Future<void> shareShowCase() async {
if (!await _confirmProceedWithPendingChanges('compartilhar')) return;
// ...corpo atual sem mudança...
}

Future<void> copyLink() async {
if (!await _confirmProceedWithPendingChanges('copiar o link')) return;
// ...corpo atual sem mudança...
}

updateShowCase() muda de Future<void> para Future<bool> (retorna se salvou com sucesso — false em validação inválida ou erro de API). Não quebra o botão ATUALIZAR VITRINE existente (onPressed: controller.updateShowCase), já que Dart aceita uma função Future<bool> Function() onde se espera void Function().

Efeito colateral: a versão atual do onWillPop() chama Get.back() manualmente e retorna true (potencial double-pop). A nova versão só retorna true/false, deixando o WillPopScope cuidar do pop — corrige isso de graça.

Casos cobertos sem código adicional:

  • Campo mantém o valor pendente após "Não salvar" (RF-06 AC11): não mexemos em nameController/expirationDate no caminho "Não salvar" — só pulamos a chamada a updateShowCase().
  • Sem aviso durante modo de criação em loading/erro (RF-06 AC12): enableEditButton já retorna false quando detail.value é nulo (Decisão #4).
  • EXCLUIR VITRINE fica fora do escopo — usa somente a confirmação de exclusão já existente (ConfirmBottomSheet), sem checagem de alterações pendentes antes.

Fora de escopo (débito técnico à parte): o botão de voltar visível da ZzAppBar chama Navigator.of(context).pop() diretamente, que não consulta onWillPop (confirmado via docs oficiais do Flutter — só Navigator.maybePop()/botão físico Android consultam). Isso significa que, mesmo com a implementação acima, o aviso de "sair" só dispara hoje pelo botão/gesto físico do Android, não pela seta visível da AppBar. Ver context/bug-appbar-back-button-ignora-onwillpop.md.


Architecture

Diagrama de Fluxo — Criar Vitrine (Novo)

Sequência: CRIAR VITRINE com Bottom Sheet

Vendedor toca em "CRIAR VITRINE"

MyShowCaseController.createFastCatalog()
├─→ GetxBottomSheet.showBottomSheet<ShowCaseTypeSelectionBottomSheet>
│ (hasSellerStock: _hasSellerStock.value)

├─ Opção: "Criar nova vitrine"
│ └─→ Get.toNamed(AppRoutes.showCaseSearchProducts)
│ → ShowCaseSearchProductsScreen (fluxo existente, sem mudança)

└─ Opção: "Vitrine do estoque da loja"
├─ Se hasSellerStock == true
│ └─→ Opção disabled + mensagem visual (sem tap)

└─ Se hasSellerStock == false
└─→ MyShowCaseController.createSellerStock()
└─→ Get.toNamed(AppRoutes.storeShowCaseDetail,
arguments: ShowCaseDetailScreenArguments(
catalogName: 'Estoque da loja', isCreating: true))

ShowCaseDetailController (modo criação)
├─→ skeleton (isLoading = true)
├─→ POST /api/show-case-new
│ (showCaseType='SellerStock', name='Estoque da loja',
│ showCaseItems=[], dateExpiration=hoje+31)

├─ 200 OK (criada OU já existente — get-or-create)
│ └─→ GET /api/show-case-new/app/{id} (encadeado)
│ └─→ Tela de detalhe renderiza normalmente
│ (branching por showCaseType, ver Decisão #4)

└─ Erro inesperado (rede, 5xx)
└─→ ZzErrorState (retry → createAndLoad() de novo)

Ao voltar da tela de detalhe (qualquer caminho): MyShowCaseController
recarrega a listagem (_hasNextPage=true; pagingController.value =
PagingState()) — igual ao padrão já usado em goToDetailFastCatalog().

Diagrama de Fluxo — Detalhe (Condicional)

Sequência: DETALHE por showCaseType

Vendedor toca em item da listagem (ou vem do fluxo de criação)

ShowCaseDetailController.loadData(showCaseId)
├─→ GET /api/show-case-new/app/{showCaseId}
│ → ShowCaseDetailResponse (agora inclui showCaseType, totalItems)

├─ Se showCaseType == ShowCaseType.store (Store / Personalizada)
│ └─→ Layout atual: nome editável, seleção de produtos, botão ADICIONAR
│ PUT envia: name, showCaseItems, dateExpiration, showCaseType='Store'
│ Reward events nativos (view/share/copy) registrados normalmente

└─ Se showCaseType == ShowCaseType.sellerStock (SellerStock)
└─→ Layout adaptado:
· Título AppBar: "Estoque da loja"
· Nome: "Estoque da loja" (desabilitado)
· Heading da seção: "Confira os produtos" (novo param sectionTitle)
· Contagem: "{totalItems} produtos recomendados" (não showCaseItems.length)
· Grid: sempre no máximo 4 items preview (productCount = min(4, items.length))
· SEM link "Todos os produtos" (não há endpoint p/ lista completa por vitrine)
· Sem botão ADICIONAR PRODUTOS · Sem ícones de remoção
· PUT envia: showCaseType='SellerStock', showCaseItems=[] forçado (API ignora tudo
exceto dateExpiration)
· ATUALIZAR VITRINE habilitado só com mudança real de data (sem exigir
showCaseItems não-vazio)
· Nenhum reward event registrado em Visualizar/Compartilhar/Copiar
(aguardando task 197192)

Componentes e Responsabilidades

ComponenteCamadaTipoResponsabilidade
MyShowCaseListScreenScreenexistente (sem mudança)Renderiza lista; delega lógica ao controller
MyShowCaseControllerControllerexistente + modificadoMigra para v2; gerencia hasSellerStock; chama bottom sheet em createFastCatalog; + createSellerStock()
ShowCaseTypeSelectionBottomSheetWidgetnovoRenderiza 2 opções (Personalizada / SellerStock) com lógica de disable
ShowCaseDetailScreenScreenexistente + modificadoBranching visual por showCaseType (título, nome, botões, textos); estado de erro no modo criação
ShowCaseDetailControllerControllerexistente + modificadoshowCaseType/totalItems; modo criação (isCreating → POST + GET encadeados); enableEditButton corrigido; PUT type=4 só efetiva dateExpiration; suprime reward events para type=4; aviso de alterações pendentes ao sair/visualizar/compartilhar/copiar (_confirmProceedWithPendingChanges)
ShowCaseDetailLayoutWidgetexistente + modificadoNovos params opcionais: hideProductActions, sectionTitle
ShowCaseBottomSheetWidgetexistente (sem mudança)Ações pós-criação da vitrine personalizada (Visualizar/Compartilhar/Copiar); não é usado no fluxo de criação SellerStock
ApiUtilsUtilexistente + modificado+ getStoreShowCaseV2Url(...); - getStoreShowCaseUrl (v1, removido — sem uso após migração)
AppRoutes / AppPagesRoutesexistente (sem mudança)Nenhuma rota nova — o fluxo de criação reaproveita AppRoutes.storeShowCaseDetail

Components and Interfaces

Novos Arquivos

ArquivoCamadaTipoDescrição
lib/screens/show_case/widgets/show_case_type_selection_bottom_sheet.dartWidgetnovoBottom sheet de escolha de modalidade (Personalizada / SellerStock)
lib/shared/enum/show_case_type.dartEnumnovoEnum com 5 valores C#; toApiString() + ShowCaseTypeParser.fromJson()
lib/models/show_case/show_case_list_v2_response.dartModelnovoResponse da listagem v2 com data, hasSellerStock, hasNext

Arquivos Modificados

ArquivoModificaçãoImpacto
lib/screens/show_case/show_case/my_show_case_controller.dartcreateFastCatalog() chama bottom sheet; _load() migra para v2; + RxBool hasSellerStock; + createSellerStock() (navega pro detalhe em modo criação, sem POST aqui); recarrega listagem ao voltarCompatível com listagem infinita existente; novo estado observável
lib/shared/utils/api_utils.dart+ getStoreShowCaseV2Url(storeId, pageNumber, search, pageSize); - getStoreShowCaseUrl (v1, removido)Novo endpoint; remove código morto
lib/models/show_case/create_show_case_request.dart+ ShowCaseType showCaseType (obrigatório, sem default); toJson() usa toApiString()Call sites existentes (ShowCaseSearchProductsController) precisam passar o campo explicitamente
lib/models/show_case/update_show_case_request.dart+ ShowCaseType showCaseType (obrigatório, sem default); toJson() usa toApiString()Idem acima
lib/models/show_case/show_case_detail_response.dart+ ShowCaseType showCaseType (default store), + int totalItems (default showCaseItems.length)Retrocompatível (defaults mantêm vitrines antigas)
lib/screens/show_case/show_case_detail/show_case_detail_screen.dartShowCaseDetailScreenArguments ganha catalogId opcional e isCreating; appBarTitle/sectionTitle/productCountText/productCount/onViewAllProducts condicionados a showCaseType; renderiza ZzErrorState quando controller.hasErrorRenderização condicional; sem quebra de prop existente no único call site (goToDetailFastCatalog)
lib/screens/show_case/show_case_detail/show_case_detail_controller.dart+ isLoading/hasError (RxBool); + createAndLoad()/retryCreate(); enableEditButton corrigido (comparação real de data, guard de showCaseItems só para type=1); updateShowCase() força showCaseItems: [] quando type=4 e passa a retornar Future<bool> (era Future<void>); suprime reward events quando type=4; + _confirmProceedWithPendingChanges() chamado por onWillPop()/viewShowCase()/shareShowCase()/copyLink() (RF-06)Sem quebra de contrato para o fluxo de visualização existente; mudança de retorno de updateShowCase() é compatível com o botão existente
lib/screens/show_case/widgets/show_case_detail_layout.dart+ hideProductActions (bool, default false), + sectionTitle (String, default 'Confira os produtos adicionados', substitui o ZZTextNew hardcoded)Novos params opcionais; sem quebra de assinatura existente
lib/screens/show_case/search/show_case_search_products_controller.dartpostFastCatalog()/putFastCatalog() passam showCaseType: ShowCaseType.store explicitamente (novo campo obrigatório no DTO)Necessário só porque showCaseType deixou de ter default
lib/theme_widgets/tag/tag.dart+ ZzTagTheme.newFeature (backgroundColor: ZZColors.successMedium, foregroundColor: ZZColors.neutralLightest)Novo valor de enum; não quebra usos existentes de ZzTag

Data Models

Novo Enum

// lib/shared/enum/show_case_type.dart
enum ShowCaseType { brand, store, recommendation, brandSeller, sellerStock }

extension ShowCaseTypeApi on ShowCaseType {
String toApiString() { /* 'Brand', 'Store', 'SellerStock', ... */ }
}

abstract class ShowCaseTypeParser {
static ShowCaseType fromJson(String? value) { /* fallback: store */ }
}

Novo Model — Listagem v2

// lib/models/show_case/show_case_list_v2_response.dart
class ShowCaseListV2Response {
final List<ShowCaseResponseItem> data;
final int total;
final int pageNumber;
final int pageSize;
final bool hasSellerStock;
final bool hasNext;

ShowCaseListV2Response({
required this.data,
required this.total,
required this.pageNumber,
required this.pageSize,
required this.hasSellerStock,
required this.hasNext,
});

factory ShowCaseListV2Response.fromJson(Map<String, dynamic> json) {
final picker = pick(json);
final dataList = picker('data').asListOrEmpty<Map<String, dynamic>>();
return ShowCaseListV2Response(
data: dataList.map((e) => ShowCaseResponseItem.fromJson(e)).toList(),
total: picker('total').asIntOrThrow(),
pageNumber: picker('pageNumber').asIntOrThrow(),
pageSize: picker('pageSize').asIntOrThrow(),
hasSellerStock: picker('hasSellerStock').asBoolOrNull() ?? false,
hasNext: picker('hasNext').asBoolOrNull() ?? false,
);
}
}

Modificados — ShowCaseDetailScreenArguments

// lib/screens/show_case/show_case_detail/show_case_detail_screen.dart (DIFF)
class ShowCaseDetailScreenArguments {
final String catalogName;
- final String catalogId;
+ final String? catalogId; // nulo enquanto a vitrine ainda não foi criada
+ final bool isCreating; // default false

ShowCaseDetailScreenArguments({
required this.catalogName,
this.catalogId,
this.isCreating = false,
});
}

Modificados — DTOs Existentes

// lib/models/show_case/create_show_case_request.dart (DIFF)
class CreateShowCaseRequest implements DefaultModelInterface {
// ... campos existentes (schema, storeId, name, etc) ...
+ final ShowCaseType showCaseType; // OBRIGATÓRIO, sem default

// toJson() adicionará 'showCaseType': showCaseType.toApiString()
}

// lib/models/show_case/update_show_case_request.dart (DIFF)
class UpdateShowCaseRequest {
// ... campos existentes ...
+ final ShowCaseType showCaseType; // OBRIGATÓRIO, sem default

// toJson() adicionará 'showCaseType': showCaseType.toApiString()
}

// lib/models/show_case/show_case_detail_response.dart (DIFF)
class ShowCaseDetailResponse {
// ... campos existentes (id, name, showCaseItems, etc) ...
+ final ShowCaseType showCaseType; // default store se ausente
+ final int totalItems; // Count total do estoque (SellerStock) ou length de items; default items.length se ausente

factory ShowCaseDetailResponse.fromJson(Map<String, dynamic> json) {
// ... código existente ...
final itemsCount = picker('showCaseitems').asListOrEmpty().length;
final totalItemsValue = picker('totalItems').asIntOrNull() ?? itemsCount;

return ShowCaseDetailResponse(
// ... campos existentes ...
showCaseType: ShowCaseTypeParser.fromJson(
picker('showCaseType').asStringOrNull(),
),
totalItems: totalItemsValue,
);
}
}

Diagrama ER — Relacionamento de Models


Error Handling

Tabela de Erros

CenárioStatus HTTPMensagemComportamentoRequisito
POST SellerStock — vitrine já existe200(N/A)Não é mais um erro. API retorna dados da vitrine existente (get-or-create); App trata como sucesso normal, carrega o detalhe pelo id retornadoRF-03 AC4
POST SellerStock (modo criação) — erro inesperado4xx/5xx / falha de rede"Ocorreu um problema" / "Tivemos um problema ao carregar os dados..."Tela de detalhe exibe ZzErrorState com retryText: 'TENTE NOVAMENTE'; onRetry chama createAndLoad() de novo (mesma mensagem genérica para qualquer causa)RF-03 AC5
GET v2 listing — hasSellerStock/hasNext ausentes200(N/A)Parse com default false para ambosRF-01
GET detalhe — showCaseType ausente200(N/A)Parse com default ShowCaseType.store → layout atualRF-04 AC1
GET detalhe — totalItems ausente200(N/A)Parse com default showCaseItems.lengthRF-04 AC1
PUT SellerStock — nome ou items alterados200(N/A)API ignora campos; só aplica dateExpiration. App já envia showCaseItems: [] explicitamente para type=4RF-04 AC8

Testing Strategy

Testes Unitários

CenárioEntradaResultado esperadoRequisito
Bottom sheet — hasSellerStock=falsecreateFastCatalog() com flag falseOpção "Vitrine do estoque da loja" habilitada, sem disable visualRF-02 AC4/5
Bottom sheet — hasSellerStock=truecreateFastCatalog() com flag trueOpção desabilitada (visual disabled), mensagem de limite exibidaRF-02 AC6/7
Bottom sheet — badge "Nova"Opção "Vitrine do estoque da loja" renderizadaZzTag com theme: ZzTagTheme.newFeature (fundo successMedium, texto neutralLightest)RF-02 AC2
Bottom sheet — tap "Criar nova vitrine"Bottom sheet abertoNavega para showCaseSearchProducts com fluxo de criaçãoRF-02 AC4
Bottom sheet — tap "Vitrine do estoque" (enabled)Bottom sheet aberto, flag=falseChama MyShowCaseController.createSellerStock(), navegando para ShowCaseDetailScreen com isCreating=trueRF-02 AC5
Criação SellerStock — POST + GET encadeadosShowCaseDetailController com arguments.isCreating=truecreateAndLoad() faz POST, extrai id de CreateShowCaseResponse, encadeia loadData() com esse id; tela renderiza dados completosRF-03 AC2/AC4
Criação SellerStock — get-or-create (vitrine já existia)POST retorna 200 com dados de vitrine existenteApp trata como sucesso; nenhuma mensagem de duplicata exibidaRF-03 AC4
Criação SellerStock — erro inesperadoPOST falha (rede/5xx)hasError=true; ZzErrorState exibido; retryCreate() chama createAndLoad() de novoRF-03 AC5
Criação SellerStock — sem seleção de produtosFluxo completoNenhuma tela ShowCaseSearchProducts nem tela intermediária de formulário exibidaRF-03 AC6
Criação SellerStock — sem reward eventcreateAndLoad() executado com sucessoNenhuma chamada a rewardEventController.register(...)RF-03 AC7
Detalhe — showCaseType=StoreloadData(id) com "Store"Layout atual: nome editável, grid de produtos, botão ADICIONAR, ícones de remoção, reward events registrados normalmenteRF-04 AC2
Detalhe — showCaseType=SellerStockloadData(id) com "SellerStock"Título "Estoque da loja"; nome "Estoque da loja" (disabled); heading "Confira os produtos"; sem ADICIONAR; contagem = "{totalItems} produtos recomendados"RF-04 AC3/4/5
Detalhe SellerStock — grid nunca excede 4totalItems=87, showCaseItems=[4 items]productCount == 4 (não 87); nenhum RangeErrorRF-04 AC6
Detalhe SellerStock — sem link "Todos os produtos"totalItems=87onViewAllProducts == null, independente de totalItemsRF-04 AC6
Detalhe SellerStock — PUT força lista vaziaAltera só dateExpirationPUT com {showCaseType: "SellerStock", dateExpiration: ..., name: "...", showCaseItems: []} (vazio, não os 4 preview)RF-04 AC8
Detalhe SellerStock — enableEditButton com data igualUsuário reabre o date picker e escolhe a mesma data já salvaenableEditButton == false (comparação real, não só "campo tocado") — vale para Store e SellerStockRF-04 AC11
Detalhe SellerStock — enableEditButton com estoque zeradoshowCaseItems=[] (loja sem estoque), data alteradaenableEditButton == true (guard de lista não-vazia não se aplica a type=4)RF-04 AC10
Detalhe SellerStock — sem reward eventsviewShowCase()/shareShowCase()/copyLink() chamados com type=4Nenhuma chamada a rewardEventController.register(...)RF-04 AC12
Detalhe SellerStock — limite de data diferenteshowCaseType=SellerStock no campo de datafinalDateRange == hoje + 30 dias (não 31, que continua valendo só para showCaseType=Store)RF-04 AC13
Criação SellerStock — data padrão do POSTcreateAndLoad() monta a requestdateExpiration == hoje + 30 diasRF-03 AC2
Detalhe — aviso ao sair com alteração pendenteonWillPop() com enableEditButton=trueModal "A vitrine possui alterações não salvas" / "Deseja salvar as alterações antes de sair?"RF-06 AC1
Detalhe — aviso ao visualizar/compartilhar/copiarviewShowCase()/shareShowCase()/copyLink() com enableEditButton=trueMesma modal, com descrição variando por ação ("...antes de visualizar?" / "...compartilhar?" / "...copiar o link?")RF-06 AC2/3/4
Detalhe — "Salvar" na modal de avisoVendedor escolhe SalvarupdateShowCase() chamado; em caso de sucesso, ação original prossegue com dado atualizadoRF-06 AC5
Detalhe — "Não salvar" na modal de avisoVendedor escolhe Não salvarAção original prossegue com o dado já salvo; campo editado continua exibindo o valor pendente na telaRF-06 AC6/AC11
Detalhe — sem alteração pendenteenableEditButton=falseAção (sair/visualizar/compartilhar/copiar) executa direto, sem modalRF-06 AC7
Detalhe — modal fechada sem escolhaanswer == nullPermanece na tela; ação original não executaRF-06 AC8
Detalhe — "Salvar" falhaupdateShowCase() retorna false (validação ou erro de API)Ação original não prossegue; vendedor permanece na telaRF-06 AC9
Detalhe — sem aviso durante criação (loading/erro)isCreating=true, detail.value == nullenableEditButton == false; sair não exibe modalRF-06 AC12
Parse v2 — hasSellerStock/hasNext ausentesJSON sem os camposAmbos default falseSem erro de parsing
Parse detalhe — showCaseType ausenteJSON sem campoShowCaseType.store (default)Retrocompatível
Parse detalhe — totalItems ausenteJSON sem campototalItems = showCaseItems.length (default)Retrocompatível
Enum ShowCaseTypefromJson('SellerStock')ShowCaseType.sellerStockType-safe
Enum ShowCaseTypefromJson('Unknown') / fromJson(null)ShowCaseType.store (fallback)Default seguro

Dependências

DependênciaStatusNota
Task 197190 — Backend v2 com showCaseType, hasSellerStock, hasNext, comportamento get-or-create no POSTPendenteBloqueia integração v2 e criação SellerStock. Contrato atualizado em 03/07 — ver context/frontend-contract-diff.md
frontend-contract-diff.md — Contrato de APIDisponívelAtualizado em 03/07/2026: get-or-create no POST (sem erro de duplicata), hasNext na listagem v2
AS-IS.md — Mapeamento de código AppDisponívelValidado em 25/06/2026; atualizado em 03/07/2026
Referências visuaisDisponívelcontext/image.png a image-3.png
context/gap-arquivos-e-props-nao-mapeados.md — Lacunas de mapeamentoDisponívelComponentes e props não capturadas no AS-IS inicial; impacto baixo no design
context/gap-paginacao-v2-hasnext.md — Incerteza hasNext vs hasSellerStockDisponívelContrato v2 pode incluir hasNext; roadmap de solução
context/gap-todos-produtos-sellerstock.md — Comportamento "Todos os produtos" SellerStockDisponívelRota showCaseAllProducts não diferencia tipo; requer validação de UX
context/bug-productcount-crash-grid-detalhe.md — Crash em grid com totalItemsInvestigadoMainAxisExtent pode quebrar se totalItems >> showCaseItems.length; sugestão: usar count efetivo de items renderizados
context/bug-textos-secao-produtos-conflito.md — Conflito de rótulosInvestigadoTextos da seção de produtos podem conflitar; sugestão: usar dois parâmetros distintos (sectionTitle + productCountText)
context/comportamento-controller-detalhe-sellerstock.md — Lógica PUT SellerStockInvestigadoPUT type=4 deve enviar apenas dateExpiration; resto é ignorado pela API; código atual envia tudo
context/fluxo-pos-criacao-e-dependencia-rewards.md — Integração Rewards pós-criaçãoDepende de 197192RewardEvent CreateShowcaseRewardEvent deve incluir showCaseType; não bloqueia layout
Task 197192 — Eventos Rewards SellerStockPendenteNão bloqueia layout; enquanto pendente, nenhum reward event é registrado para ações de SellerStock

Checklist de Qualidade

  • Todos os requisitos funcionais cobertos (RF-01 a RF-05, com RF-03 reescrito)
  • Tratamento de erro genérico (com retry) só para falhas inesperadas — duplicata deixou de ser um caminho de erro
  • productCount nunca deriva de totalItems diretamente (grid sempre capada em 4)
  • Textos da seção de produtos corretos por tipo (sectionTitle + productCountText, dois parâmetros distintos)
  • Novo fluxo não quebra vitrines personalizadas existentes (retrocompatibilidade)
  • Nenhuma rota/tela/controller novo para a criação SellerStock — reaproveita ShowCaseDetailScreen
  • enableEditButton com comparação real de data (Store e SellerStock) e sem guard de estoque vazio para SellerStock
  • Nenhum reward event registrado para ações de SellerStock (criação, visualizar, compartilhar, copiar) enquanto a task 197192 estiver pendente
  • showCaseType obrigatório (sem default) em CreateShowCaseRequest/UpdateShowCaseRequest; call sites existentes atualizados
  • ApiUtils.getStoreShowCaseUrl (v1) removido
  • Enum ShowCaseType elimina magic numbers
  • Parse de v2 não quebra DefaultApiController para outros fluxos
  • Testes cobrindo branching por tipo, defaults de parse, desabilitar opção com mensagem, POST+GET encadeados, retry
  • Aviso de alterações não salvas cobrindo as 4 ações (sair, visualizar, compartilhar, copiar link), com o mesmo critério (enableEditButton) e helper compartilhado (_confirmProceedWithPendingChanges)
  • Padrão visual (bottom sheet, skeleton, estado de erro) consistente com app existente