📱 Módulo 11: App Mobile - Integração HTTP, JWT e Persistência Local

✅ Pré-Requisitos deste Módulo

Confirme antes de começar:

# dentro da pasta mobile/
ionic serve   # deve abrir localhost:8100 sem erros

O backend deve estar rodando (./mvnw spring-boot:run) para que o login funcione. Você deve ter concluído o Módulo 10 (projeto Ionic criado, catálogo mock funcionando).


No módulo anterior, criamos a interface visual (casca) do nosso aplicativo usando Ionic UI. Agora, precisamos conectar o aplicativo de forma segura à nossa API REST Java (Spring Boot), permitindo que o usuário faça login pelo celular, armazene seu token e liste produtos reais.


🔐 Persistência Segura no Celular

Na web, costumamos usar o localStorage. No entanto, em um aplicativo móvel nativo, o sistema operacional (Android/iOS) pode limpar o armazenamento interno do navegador indiscriminadamente se faltar memória.

Para resolver isso, usaremos o Capacitor Preferences (Storage), que salva os tokens nativamente usando SharedPreferences (Android) ou NSUserDefaults (iOS).

sequenceDiagram
    participant App as App Ionic
    participant Cap as Capacitor Storage
    participant API as Spring Boot API

    App->>API: POST /api/auth/login (email/senha)
    API-->>App: { token: "eyJhG..." }
    App->>Cap: Preferences.set({ key: 'jwt', value: token })
    
    Note over App, API: Próxima vez que abrir o app...
    App->>Cap: Preferences.get({ key: 'jwt' })
    Cap-->>App: Retorna o token salvo nativamente
    App->>API: GET /api/produtos + Header Bearer

🛠️ 1. Configurando o Capacitor Storage e HTTP

Instale o plugin nativo de preferências do Capacitor no terminal dentro da pasta mobile:

npm install @capacitor/preferences

Ative as rotinas HTTP no módulo raiz do Ionic (src/app/app.module.ts ou main.ts, dependendo da versão Standalone) importan### O Serviço de Autenticação

Crie um serviço encarregado do login:

ionic g service services/auth
// src/app/services/auth.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs';
import { Preferences } from '@capacitor/preferences';

export interface LoginResponse {
  token: string;
  username: string;
  role: string;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private http = inject(HttpClient);
  private router = inject(Router);
  
  // Aponta para a API local na porta 8080
  private apiUrl = 'http://localhost:8080/api/v1/auth';

  // Signal Reativo para gerenciar o estado na UI mobile de forma instantânea
  currentUser = signal<{ username: string; role: string } | null>(null);

  constructor() {
    this.carregarCredenciaisNativas();
  }

  login(username: string, senha: string): Observable<LoginResponse> {
    return this.http.post<LoginResponse>(`${this.apiUrl}/login`, { username, senha }).pipe(
      tap(async response => {
        // Gravação segura no Sandbox nativo do iOS/Android/Web via Capacitor Preferences
        await Preferences.set({ key: 'tecloja_token', value: response.token });
        await Preferences.set({ key: 'tecloja_username', value: response.username });
        await Preferences.set({ key: 'tecloja_role', value: response.role });

        // Atualiza o Signal reativo principal
        this.currentUser.set({ username: response.username, role: response.role });
      })
    );
  }

  async logout(): Promise<void> {
    await Preferences.remove({ key: 'tecloja_token' });
    await Preferences.remove({ key: 'tecloja_username' });
    await Preferences.remove({ key: 'tecloja_role' });
    
    this.currentUser.set(null);
    this.router.navigate(['/login']);
  }

  async getToken(): Promise<string | null> {
    const { value } = await Preferences.get({ key: 'tecloja_token' });
    return value;
  }

  async isAuthenticated(): Promise<boolean> {
    const token = await this.getToken();
    return token !== null;
  }

  async isAdmin(): Promise<boolean> {
    const { value } = await Preferences.get({ key: 'tecloja_role' });
    return value === 'ROLE_ADMIN';
  }

  private async carregarCredenciaisNativas(): Promise<void> {
    const { value: username } = await Preferences.get({ key: 'tecloja_username' });
    const { value: role } = await Preferences.get({ key: 'tecloja_role' });

    if (username && role) {
      this.currentUser.set({ username, role });
    }
  }
}

⚡ 2. Interceptador HTTP Global

Assim como fizemos no projeto Web, precisamos de um mecanismo automático que acople o token a todas as requisições, afinal, o catálogo só é exibido se o app estiver logado.

No Angular, implementamos isso usando HttpInterceptorFn:

// src/app/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { from, switchMap } from 'rxjs';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);

  // Precisamos converter a promessa do Capacitor em um Observable do RxJS
  return from(authService.getToken()).pipe(
    switchMap(token => {
      if (token) {
        // Clona a requisição injetando o cabeçalho Authorization
        const reqClonada = req.clone({
          setHeaders: { Authorization: `Bearer ${token}` }
        });
        return next(reqClonada);
      }
      return next(req);
    })
  );
};

🎨 3. Construindo a Tela de Login

Na página de Login (app/pages/login/), utilizaremos inputs standalone reativos com design dark-glassmorphism imitando a estética indiana do portal:

// src/app/pages/login/login.page.ts
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { 
  IonContent, 
  IonCard, 
  IonCardHeader, 
  IonCardTitle, 
  IonCardSubtitle, 
  IonCardContent, 
  IonItem, 
  IonInput, 
  IonButton, 
  IonSpinner 
} from '@ionic/angular/standalone';

@Component({
  selector: 'app-login',
  template: `
    <ion-content class="login-content">
      <div class="login-wrapper">
        <div class="logo-area">
          <div class="logo-circle"><i class="bi bi-cpu"></i></div>
          <h1>TecLoja</h1>
          <p>Experiência Mobile Omnichannel</p>
        </div>

        <ion-card class="login-card">
          <ion-card-header>
            <ion-card-title>Acesse sua conta</ion-card-title>
            <ion-card-subtitle>Insira seus dados para continuar</ion-card-subtitle>
          </ion-card-header>

          <ion-card-content>
            <form (ngSubmit)="onSubmit()" #loginForm="ngForm">
              <div class="form-group">
                <label class="form-label">E-mail</label>
                <div class="input-wrapper">
                  <i class="bi bi-envelope input-icon"></i>
                  <ion-input type="email" name="username" placeholder="exemplo@gmail.com" [(ngModel)]="username" required></ion-input>
                </div>
              </div>

              <div class="form-group">
                <label class="form-label">Senha</label>
                <div class="input-wrapper">
                  <i class="bi bi-lock input-icon"></i>
                  <ion-input type="password" name="senha" placeholder="••••••••" [(ngModel)]="senha" required></ion-input>
                </div>
              </div>

              <ion-button type="submit" expand="block" class="login-btn" [disabled]="loading() || !loginForm.valid">
                <span *ngIf="loading()"><ion-spinner name="crescent" size="small"></ion-spinner> Entrando...</span>
                <span *ngIf="!loading()">Entrar na Conta <i class="bi bi-chevron-right"></i></span>
              </ion-button>
            </form>
          </ion-card-content>
        </ion-card>
      </div>
    </ion-content>
  `
})
export class LoginPage {
  auth = inject(AuthService);
  router = inject(Router);

  username = '';
  senha = '';

  loading = signal(false);

  onSubmit(): void {
    if (!this.username || !this.senha) return;
    this.loading.set(true);

    this.auth.login(this.username, this.senha).subscribe({
      next: () => {
        this.loading.set(false);
        this.router.navigate(['/home']);
      },
      error: () => {
        this.loading.set(false);
        alert('Credenciais inválidas ou sem sinal com a API!');
      }
    });
  }
}

🤔 Por que Capacitor Preferences e não localStorage?

No navegador, localStorage é suficiente. Mas em um app nativo instalado no Android ou iOS, a WebView que executa o código Angular pode ter seu armazenamento limpo pelo sistema operacional quando o celular está com pouca memória — causando logout inesperado do usuário.

O Capacitor Preferences salva os dados nos mecanismos nativos do SO: SharedPreferences no Android e NSUserDefaults no iOS. Esses armazenamentos são persistentes e isolados por app, só sendo removidos quando o usuário desinstala o aplicativo.

Note que o getToken() retorna uma Promise (assíncrono), diferente do AuthService web que usa localStorage de forma síncrona. Por isso o interceptor mobile usa from(authService.getToken()).pipe(switchMap(...)) para converter a Promise em Observable do RxJS.

🔍 Checkpoint

Com o backend rodando, teste o login no app mobile:

  1. Acesse http://localhost:8100/login no navegador
  2. Insira admin@tecloja.com / admin123 e clique em Entrar
  3. Você deve ser redirecionado para /home
  4. Abra DevTools (F12) → Application → IndexedDB (Capacitor usa IndexedDB no browser para simular as Preferences)
  5. Procure pela chave tecloja_token — deve conter o JWT

Resultado esperado: Login bem-sucedido, token armazenado, redirecionamento para /home com produtos reais da API.

⚠️ Erros Comuns

Sintoma Causa Solução
Cannot find module '@capacitor/preferences' Plugin não instalado Execute npm install @capacitor/preferences dentro da pasta mobile/
TS2345: Argument of type 'Promise<...>' no interceptor getToken() retorna Promise, não Observable Envolva com from() do RxJS: from(authService.getToken()).pipe(switchMap(...))
Login bem-sucedido mas não redireciona Router não injetado na LoginPage Confirme private router = inject(Router) e this.router.navigate(['/home']) no next do subscribe
ERR_CONNECTION_REFUSED ao fazer login Backend não está rodando Execute ./mvnw spring-boot:run na pasta backend/
CORS bloqueando requisição do mobile Origem do Ionic não permitida Confirme http://localhost:8100 na lista setAllowedOriginPatterns do SecurityConfig

🏁 Conclusão

Com o fluxo de login em perfeito funcionamento e o Token sendo recuperado de forma persistente a cada nova requisição pelo Interceptador HTTP, nossa arquitetura Mobile com o Spring Boot está homologada!

No Módulo 12, o último deste ecossistema, utilizaremos os plugins avançados do Capacitor para tirar fotos de produtos utilizando a câmera nativa do celular e ensinaremos o fluxo para empacotar o código em um arquivo APK pronto para rodar em dispositivos Android reais!


Voltar para o Sumário