📱 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 umaPromise(assíncrono), diferente doAuthServiceweb que usalocalStoragede forma síncrona. Por isso o interceptor mobile usafrom(authService.getToken()).pipe(switchMap(...))para converter a Promise em Observable do RxJS.
🔍 Checkpoint
Com o backend rodando, teste o login no app mobile:
- Acesse
http://localhost:8100/loginno navegador - Insira
admin@tecloja.com/admin123e clique em Entrar - Você deve ser redirecionado para
/home - Abra DevTools (F12) → Application → IndexedDB (Capacitor usa IndexedDB no browser para simular as Preferences)
- 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!