O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos

O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos

O aplicativo do cliente para iPhone e Android em .NET MAUI: Shell com abas e rotas, catálogo com rolagem infinita por cursor, login pelo navegador do sistema, tokens no SecureStorage, as armadilhas de rede do emulador e uma conexão em tempo real que respeita o ciclo de vida do celular.
Linguagem C#

• • 19 min de leitura

O último canal da Livraria é o que fica no bolso do cliente. Neste artigo construímos o aplicativo para iPhone e Android em .NET MAUI, com o mesmo MVVM do caixa. As diferenças que importam vêm do celular: navegação por abas e pilhas, listas que rolam para sempre, login pelo navegador do sistema, armazenamento seguro, e um sistema operacional que pode encerrar o aplicativo a qualquer momento.

O projeto e o que ele gera

dotnet workload install maui
dotnet new maui -n Livraria.App -o src/Livraria.App
dotnet add src/Livraria.App reference src/Livraria.Contratos
dotnet add src/Livraria.App package CommunityToolkit.Mvvm
dotnet add src/Livraria.App package CommunityToolkit.Maui
dotnet add src/Livraria.App package Microsoft.Extensions.Http.Resilience
dotnet add src/Livraria.App package Microsoft.AspNetCore.SignalR.Client

O MAUI usa um projeto único que gera um aplicativo por plataforma. O código comum fica na raiz, e o que é específico de cada plataforma fica em Platforms/Android, Platforms/iOS, Platforms/MacCatalyst e Platforms/Windows, com os manifestos e os pontos de entrada nativos. Imagens, fontes e ícones em Resources/ são convertidos para os formatos e densidades de cada sistema durante o build. Um único SVG vira os ícones em todas as resoluções que Android e iOS exigem.

Para compilar para iOS é preciso um Mac com Xcode, por exigência da Apple: o Visual Studio no Windows pode se conectar a um Mac na rede para isso. Android compila em qualquer sistema.

MauiProgram: o mesmo contêiner de sempre

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder.UseMauiApp<App>()
               .UseMauiCommunityToolkit()
               .ConfigureFonts(f => f.AddFont("Inter-Regular.ttf", "Inter"));

        builder.Services.AddSingleton<SessaoDoCliente>();
        builder.Services.AddSingleton<CarrinhoLocal>();
        builder.Services.AddTransient<AnexarToken>();

        builder.Services.AddHttpClient<ClienteDaLivraria>(c => c.BaseAddress = new Uri(Configuracao.UrlDaApi))
            .AddHttpMessageHandler<AnexarToken>()
            .AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods());

        builder.Services.AddTransient<CatalogoViewModel>();
        builder.Services.AddTransient<CatalogoPage>();
        builder.Services.AddTransient<ProdutoViewModel>();
        builder.Services.AddTransient<ProdutoPage>();
        builder.Services.AddTransient<CarrinhoViewModel>();
        builder.Services.AddTransient<CarrinhoPage>();
        builder.Services.AddTransient<PedidosViewModel>();
        builder.Services.AddTransient<PedidosPage>();

        return builder.Build();
    }
}

É o MauiAppBuilder, com o mesmo IServiceCollection, o mesmo AddHttpClient e a mesma resiliência da Fase 6. Diferente do Blazor Server de "Login, área administrativa, upload de capas e notificações em tempo real", aqui o DelegatingHandler que anexa o token funciona sem armadilhas de escopo: o aplicativo tem um usuário, e a sessão é um singleton.

Shell: abas, pilhas e rotas

O Shell é a estrutura de navegação do MAUI. Ele descreve a hierarquia do aplicativo numa só página:

<!-- AppShell.xaml -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:p="clr-namespace:Livraria.App.Paginas"
       x:Class="Livraria.App.AppShell">
    <TabBar>
        <Tab Title="Catálogo" Icon="livros.png">
            <ShellContent ContentTemplate="{DataTemplate p:CatalogoPage}" Route="catalogo" />
        </Tab>
        <Tab Title="Carrinho" Icon="carrinho.png">
            <ShellContent ContentTemplate="{DataTemplate p:CarrinhoPage}" Route="carrinho" />
        </Tab>
        <Tab Title="Pedidos" Icon="pedidos.png">
            <ShellContent ContentTemplate="{DataTemplate p:PedidosPage}" Route="pedidos" />
        </Tab>
    </TabBar>
</Shell>
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute("produto", typeof(ProdutoPage));
    }
}

Cada aba tem sua própria pilha de navegação: o cliente abre um livro a partir do catálogo, troca para a aba de pedidos, volta, e continua na página do livro. É o comportamento que usuários de iPhone e Android esperam. O DataTemplate faz a página ser criada só quando a aba é aberta pela primeira vez, e como as páginas estão registradas no contêiner, o Shell as resolve pela DI com os ViewModels injetados.

A navegação usa rotas no estilo URL:

await Shell.Current.GoToAsync("produto", new Dictionary<string, object> { ["id"] = produto.Id });

A página de destino recebe os parâmetros implementando IQueryAttributable no ViewModel:

public sealed partial class ProdutoViewModel(ClienteDaLivraria api) : ObservableObject, IQueryAttributable
{
    [ObservableProperty] private ProdutoResposta? produto;
    [ObservableProperty] private bool carregando;

    public async void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("id", out var valor) && valor is int id)
            await CarregarAsync(id);
    }
    // ...
}

Este é um dos raros lugares em que async void é aceitável: um método de interface síncrono, chamado pelo framework, que inicia trabalho assíncrono. A exceção precisa ser tratada dentro de CarregarAsync, porque uma exceção que escapa de async void derruba o aplicativo, como lembramos em "async e await: Esperar Sem Bloquear".

O catálogo: rolagem infinita

No celular não há links de "próxima página". O cliente rola, e mais livros aparecem. O CollectionView pede mais itens quando faltam poucos para o fim:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:Livraria.App.ViewModels"
             xmlns:c="clr-namespace:Livraria.Contratos;assembly=Livraria.Contratos"
             x:Class="Livraria.App.Paginas.CatalogoPage"
             x:DataType="vm:CatalogoViewModel"
             Title="Catálogo">

    <RefreshView IsRefreshing="{Binding Atualizando}" Command="{Binding AtualizarCommand}">
        <CollectionView ItemsSource="{Binding Produtos}"
                        RemainingItemsThreshold="5"
                        RemainingItemsThresholdReachedCommand="{Binding CarregarMaisCommand}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="c:ProdutoResposta">
                    <Grid ColumnDefinitions="80,*" Padding="12" ColumnSpacing="12">
                        <Grid.GestureRecognizers>
                            <TapGestureRecognizer
                                Command="{Binding Source={RelativeSource AncestorType={x:Type vm:CatalogoViewModel}}, Path=AbrirCommand}"
                                CommandParameter="{Binding .}" />
                        </Grid.GestureRecognizers>
                        <Image Source="{Binding CapaUrl}" HeightRequest="120" Aspect="AspectFit"
                               SemanticProperties.Description="{Binding Titulo, StringFormat='Capa de {0}'}" />
                        <VerticalStackLayout Grid.Column="1" Spacing="4">
                            <Label Text="{Binding Titulo}" FontAttributes="Bold" />
                            <Label Text="{Binding Autor}" />
                            <Label Text="{Binding Preco, StringFormat='{0:C}'}" />
                        </VerticalStackLayout>
                    </Grid>
                </DataTemplate>
            </CollectionView.ItemTemplate>
            <CollectionView.EmptyView>
                <Label Text="Nenhum livro encontrado." HorizontalOptions="Center" />
            </CollectionView.EmptyView>
        </CollectionView>
    </RefreshView>
</ContentPage>
public sealed partial class CatalogoViewModel(ClienteDaLivraria api) : ObservableObject
{
    private int? cursor;
    private bool fim;

    public ObservableCollection<ProdutoResposta> Produtos { get; } = [];

    [ObservableProperty] private bool atualizando;

    [RelayCommand]
    private async Task AtualizarAsync(CancellationToken ct)
    {
        cursor = null; fim = false;
        Produtos.Clear();
        await CarregarMaisAsync(ct);
        Atualizando = false;
    }

    [RelayCommand]
    private async Task CarregarMaisAsync(CancellationToken ct)
    {
        if (fim) return;
        var pagina = await api.ListarAsync(depoisDe: cursor, tamanho: 20, ct);
        foreach (var p in pagina.Itens) Produtos.Add(p);
        cursor = pagina.ProximoCursor;
        fim = cursor is null;
    }

    [RelayCommand]
    private Task AbrirAsync(ProdutoResposta produto) =>
        Shell.Current.GoToAsync("produto", new Dictionary<string, object> { ["id"] = produto.Id });
}

A paginação por keyset do exercício 2 de "A API da Livraria" ganha aqui o cliente para o qual foi pensada. Com offset, a página 50 custaria caro no banco, e um livro cadastrado enquanto o cliente rola deslocaria todos os itens, fazendo um aparecer duas vezes. Com o cursor, cada lote continua exatamente de onde o anterior parou. AsyncRelayCommand não permite execuções simultâneas, então uma rolagem rápida que dispara o limite várias vezes não carrega o mesmo lote em dobro.

O CollectionView recicla as células: com mil livros carregados, só as vinte e poucas visíveis existem como controles nativos, e rolar só troca os dados delas. O x:DataType no template também importa aqui: com bindings compilados, cada célula reciclada é preenchida sem reflexão, e a rolagem fica suave em aparelhos modestos.

As capas vêm das URLs imutáveis de "Login, área administrativa, upload de capas e notificações em tempo real". O Image do MAUI guarda imagens remotas em cache no disco, e como a URL muda quando a capa muda, o cache nunca serve uma capa desatualizada. A SemanticProperties.Description é o que o VoiceOver e o TalkBack leem para quem não enxerga a capa.

Login no celular

O fluxo é o mesmo do caixa (RFC 8252, cliente público, PKCE), com duas diferenças da plataforma. O retorno não é uma porta local, e sim um esquema de URL próprio, livraria://callback. E quem abre o navegador é o WebAuthenticator do MAUI, que usa o ASWebAuthenticationSession no iOS e as Custom Tabs no Android, navegadores seguros do sistema, isolados do aplicativo:

public sealed class NavegadorMaui : IBrowser
{
    public async Task<BrowserResult> InvokeAsync(BrowserOptions opcoes, CancellationToken ct = default)
    {
        try
        {
            var resultado = await WebAuthenticator.Default.AuthenticateAsync(
                new Uri(opcoes.StartUrl), new Uri(opcoes.EndUrl));

            var retorno = new UriBuilder(opcoes.EndUrl)
            {
                Query = string.Join("&", resultado.Properties.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"))
            };
            return new BrowserResult { Response = retorno.ToString(), ResultType = BrowserResultType.Success };
        }
        catch (TaskCanceledException)
        {
            return new BrowserResult { ResultType = BrowserResultType.UserCancel };
        }
    }
}

O OidcClient do artigo anterior recebe esse navegador e RedirectUri = "livraria://callback". No Android, uma atividade derivada de WebAuthenticatorCallbackActivity, com um IntentFilter para o esquema livraria, recebe o retorno. No iOS, o esquema é declarado no Info.plist.

Os tokens vão para o SecureStorage, que usa o Keychain no iOS e o Keystore no Android, protegidos pelo hardware do aparelho quando disponível:

public sealed class SessaoDoCliente
{
    public async Task SalvarAsync(LoginResult login)
    {
        await SecureStorage.Default.SetAsync("refresh_token", login.RefreshToken);
        AccessToken = login.AccessToken;
        ExpiraEm = login.AccessTokenExpiration;
        ClienteId = login.User.FindFirst("sub")?.Value;
    }
    // ObterAccessTokenAsync renova quando necessário, com a mesma trava do ArmazemDeTokens do site
}

Preferences, a outra API de armazenamento do MAUI, é texto simples num arquivo do aplicativo. Serve para o tema escolhido ou o último CEP digitado, e nunca para tokens. O access token fica só na memória: se o sistema encerrar o aplicativo, ele é obtido de novo a partir do refresh token na próxima abertura.

As armadilhas do ambiente de desenvolvimento

Três problemas pegam todo mundo na primeira vez que um aplicativo MAUI chama uma API local:

  • localhost no emulador Android é o próprio emulador, e não o computador. A máquina hospedeira é alcançada por 10.0.2.2. No simulador iOS, localhost funciona, porque ele roda como processo do Mac. Num aparelho físico, nenhum dos dois funciona.
  • HTTP em texto claro é bloqueado por padrão. O iOS exige HTTPS pela política App Transport Security, e o Android bloqueia tráfego sem TLS a partir da versão 9. Relaxar essas regras só em builds de depuração, por configuração de segurança de rede, é possível, mas um esquecimento chega às lojas.
  • O certificado de desenvolvimento do ASP.NET Core não é confiável no aparelho.

A saída mais limpa é um túnel HTTPS com certificado válido para a API local, como os dev tunnels do Visual Studio e da CLI devtunnel. O aplicativo usa HTTPS em todo lugar, e a configuração de depuração muda só a URL base.

O carrinho do aplicativo

No site, o carrinho mora no servidor do site, atrás do cookie e do BFF, como vimos em "Blazor: modos de renderização, WebAssembly e estado". O aplicativo não tem esse servidor, e há duas opções:

  1. Carrinho local, guardado no aparelho (um arquivo JSON ou SQLite), enviado à API só na finalização, como POST /v1/pedidos com Idempotency-Key.
  2. Carrinho como recurso da API (/v1/carrinhos/meu), compartilhado entre o site, o aplicativo e qualquer outro canal do mesmo cliente.

A segunda opção é o que o cliente espera de uma loja grande: adicionar no celular e finalizar no computador. Mas ela exige um recurso novo na API, com persistência nos três bancos e regras de expiração e de mesclagem quando um visitante anônimo faz login. A Livraria começa com o carrinho local no aplicativo e registra a decisão: se o carrinho compartilhado virar requisito, ele sobe para a API, e os dois front-ends passam a consumi-lo. Mudar de ideia é barato porque a tela depende de uma interface ICarrinho, e não do armazenamento.

Acompanhando o pedido

Com o aplicativo aberto, a página de pedidos se conecta ao hub da Livraria com o token do cliente e recebe PedidoRegistrado e, quando a logística existir, PedidoAtualizado. É o mesmo cliente SignalR do caixa e do painel administrativo. O ciclo de vida do celular, porém, muda as regras:

public partial class App : Application
{
    protected override Window CreateWindow(IActivationState? estado)
    {
        var janela = new Window(new AppShell());
        janela.Stopped += async (_, _) => await tempoReal.PausarAsync();     // foi para o fundo
        janela.Resumed += async (_, _) => await tempoReal.RetomarAsync();    // voltou
        return janela;
    }
}

Quando o aplicativo vai para segundo plano, o sistema pode suspendê-lo em segundos e, no Android, encerrá-lo para liberar memória. Uma conexão WebSocket aberta em segundo plano gasta bateria e seria cortada de qualquer jeito. O aplicativo fecha a conexão ao ir para o fundo e, ao voltar, reconecta e relê os pedidos, porque as mensagens enviadas enquanto estava fora se perderam, a regra de "Tempo real com SignalR" em outro cenário.

Para avisar o cliente com o aplicativo fechado ("seu pedido saiu para entrega"), o mecanismo é outro: notificações push, entregues pelos serviços dos sistemas (APNs da Apple e Firebase Cloud Messaging do Google). O aplicativo registra o aparelho e envia o identificador de push à API. Um consumidor no Livraria.Worker, escutando pedido.atualizado pelo RabbitMQ, envia a notificação pelo serviço correspondente, diretamente ou via um serviço intermediário como o Azure Notification Hubs. A arquitetura de eventos da Fase 6 absorve mais um canal com um consumidor a mais, sem mudar nada no fluxo da venda.

Quando é preciso descer ao nativo

O MAUI mapeia cada controle para o equivalente nativo por meio de handlers. Quando o visual padrão não serve, o mapeamento pode ser ajustado para todos os controles de um tipo:

Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("SemSublinhado", (handler, entrada) =>
{
#if ANDROID
    handler.PlatformView.BackgroundTintList =
        Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
#elif IOS
    handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});

handler.PlatformView é o controle nativo: um AppCompatEditText no Android, um UITextField no iOS. As diretivas #if escolhem o código de cada plataforma na compilação. É o modelo de "controles nativos" do artigo anterior visto de perto: o MAUI não desenha o campo, ele configura o campo do sistema. Para recursos de aparelho, como câmera, localização, biometria e compartilhamento, as APIs do MAUI (antigo Xamarin.Essentials) cobrem os casos comuns com uma interface única. Um leitor de código de barras pela câmera, para o cliente procurar um livro pelo ISBN numa livraria concorrente, usa uma biblioteca da comunidade como a ZXing.Net.Maui, que também lida com a permissão de câmera.

Publicação

Cada loja tem seu ritual. No Android, o aplicativo é assinado com uma chave própria, guardada com o mesmo cuidado que os segredos de produção, porque perdê-la impede atualizar o aplicativo, e é enviado como pacote AAB à Google Play. No iOS, exige conta de desenvolvedor da Apple, certificados, perfis de provisionamento e revisão da loja. Os dois processos podem rodar em CI, com as chaves nos segredos do pipeline, como no deploy de "Deploy: contêineres, nginx, Linux e a orquestração com Aspire". No iOS, o MAUI compila com AOT completo por exigência da plataforma, que proíbe gerar código em tempo de execução. É mais um motivo para os bindings compilados e para evitar reflexão.

Com o aplicativo do cliente, a Fase 8 se completa. A mesma API atende agora quatro interfaces: o site, com páginas renderizadas no servidor e ilhas interativas; o caixa, em desktop multiplataforma que continua vendendo offline; o aplicativo, nativo em iPhone e Android; e o painel dos administradores. Todas com a mesma identidade, os mesmos contratos e o mesmo tempo real. O que muda de uma para outra é a camada de cima: layout, navegação e ciclo de vida. Os modelos, as regras e os protocolos são os mesmos, porque foram separados das telas desde a Fase 5. Resta um programa de natureza diferente de tudo o que construímos: um jogo, que não espera eventos, mas roda sessenta vezes por segundo, cada vez com um orçamento de milissegundos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um desenvolvedor configurou UrlDaApi = "http://localhost:5080" e o aplicativo funciona no simulador iOS e no Windows, mas no emulador Android todas as chamadas falham. Liste as duas causas e a correção recomendada.

Ver resposta

✓ Resposta: (1) No emulador Android, localhost aponta para o próprio emulador, que não tem a API. A máquina hospedeira fica em 10.0.2.2. (2) Mesmo com o endereço certo, o Android bloqueia HTTP sem TLS por padrão desde a versão 9, e a chamada falha com um erro de cleartext traffic not permitted. O simulador iOS funcionou provavelmente porque o endereço localhost resolve para o Mac, mas também esbarraria na App Transport Security em HTTP sem uma exceção. A correção recomendada é expor a API local por um túnel HTTPS com certificado válido e usar essa URL em todas as plataformas nas configurações de depuração, sem relaxar as políticas de segurança do sistema. Isso também funciona em aparelhos físicos, o que 10.0.2.2 não resolve.

Exercício 2

Por que o catálogo do aplicativo usa paginação por cursor, e o que o cliente veria de estranho com paginação por offset se um administrador cadastrasse cinco livros enquanto ele rolava?

Ver resposta

✓ Resposta: Com offset (pagina=3&tamanho=20), cada lote é "pule N itens e pegue 20" contra a ordem atual do banco. Se cinco livros forem cadastrados antes da posição do cliente na ordenação (por exemplo, ordenados por título, e os novos começam com "A"), todos os itens se deslocam cinco posições. O próximo lote começaria cinco itens antes de onde o anterior terminou, e o cliente veria cinco livros repetidos. Se livros fossem removidos, ele pularia livros sem perceber. Com cursor (depoisDe=último id), cada lote começa exatamente depois do último item visto, e inserções e remoções em outras partes da lista não afetam a continuação. Para a rolagem infinita, em que o cliente nunca volta a uma "página 3" específica, o cursor é a escolha natural, além de manter o custo de cada lote constante no banco.

Exercício 3

Um colega guardou o refresh token em Preferences "porque é mais simples e o celular já tem senha". Explique o risco e por que o SecureStorage é diferente.

Ver resposta

✓ Resposta: Preferences grava em texto simples num arquivo do diretório do aplicativo (SharedPreferences no Android, NSUserDefaults no iOS). A senha do aparelho protege contra alguém que pega o celular bloqueado, mas não contra: backups do aparelho, que podem incluir esses arquivos e ir para a nuvem ou para um computador; aparelhos com root ou jailbreak, onde qualquer aplicativo lê os arquivos dos outros; ferramentas de depuração num aparelho de desenvolvedor; e malware com privilégios elevados. O refresh token é a credencial de maior valor, de vida longa, que obtém novos access tokens sem senha. O SecureStorage usa o Keychain e o Keystore, com a chave de criptografia no hardware seguro quando disponível. O valor fica criptografado com uma chave que nem o aplicativo consegue exportar, e as regras de backup do sistema tratam esses itens de forma diferente. Não é invulnerável, mas eleva muito o custo do ataque.

Exercício 4

O cliente abre o aplicativo, vê o pedido "Registrado", vai para outro aplicativo por dez minutos e volta. Nesse meio-tempo o pedido mudou para "Enviado". Descreva, passo a passo, como o aplicativo mostra o estado correto.

Ver resposta

✓ Resposta: Ao ir para segundo plano, o evento Stopped da janela chama PausarAsync, que encerra a conexão SignalR. Nos dez minutos, a API envia PedidoAtualizado ao usuário, mas não há conexão desse aparelho, e a mensagem se perde. Se as notificações push estiverem ativas, o Worker envia "Seu pedido foi enviado", que aparece na tela do sistema. Ao voltar, o evento Resumed chama RetomarAsync, que: (1) obtém um access token válido (renovando pelo refresh token, se expirou nos dez minutos); (2) reabre a conexão com o hub, e o servidor recoloca o usuário nos grupos certos; (3) relê os pedidos pela API, com If-None-Match se o ETag estiver guardado, e recebe o estado "Enviado". A exibição fica correta pela releitura, não pela mensagem perdida. Se o sistema tiver encerrado o aplicativo nesse tempo, o fluxo é o de uma abertura nova, com o mesmo resultado.

Exercício 5

A Livraria decide que o carrinho deve ser compartilhado entre o site e o aplicativo. Esboce o recurso na API (rotas, dono, concorrência) e o que acontece quando um visitante anônimo com três livros no carrinho do site faz login e já tem dois livros no carrinho do aplicativo.

Ver resposta

✓ Resposta: Recurso GET/PUT /v1/carrinhos/meu, sempre do usuário do token (nunca um id na URL, evitando o BOLA de "Autenticação e autorização com JWT"), com ETag e If-Match para concorrência otimista entre aparelhos. O PUT substitui a lista inteira de itens, e operações parciais podem ser POST /v1/carrinhos/meu/itens e DELETE /v1/carrinhos/meu/itens/{produtoId}. Persistência nos três bancos, ou num armazenamento com expiração, e evento carrinho.alterado para avisar outros aparelhos pelo hub. Na mesclagem, o site, pelo BFF, detecta o login com um carrinho anônimo não vazio no cookie e chama POST /v1/carrinhos/meu/mesclar com os itens anônimos. A regra de negócio precisa ser escolhida e documentada: a mais comum é a união, somando as quantidades de produtos repetidos até o limite por item. O resultado é cinco títulos (ou menos, se houver sobreposição). O carrinho anônimo é então apagado. O aplicativo, na próxima leitura ou pelo evento no hub, passa a mostrar os cinco. Alternativas como "o mais recente vence" são mais simples e surpreendem mais o cliente, e por isso a regra precisa estar explícita.

Comentários

Mais em Linguagem C#

Autenticação e autorização com JWT: quem é você e o que pode fazer
Autenticação e autorização com JWT: quem é você e o que pode fazer

Quem pode fazer o quê na API da Livraria: 401 contra 403, a anatomia do JWT…

SQL Essencial com SQLite: Perguntas a um Banco de Dados
SQL Essencial com SQLite: Perguntas a um Banco de Dados

O modelo relacional e a linguagem SQL na prática, com SQLite: tabelas STRICT…

Login, área administrativa, upload de capas e notificações em tempo real
Login, área administrativa, upload de capas e notificações em tempo real

O site da Livraria ganha login por OpenID Connect com Keycloak e PKCE, tokens…