bceao_pispi_app 0.0.5
bceao_pispi_app: ^0.0.5 copied to clipboard
SDK Flutter permettant d'intégrer PI-SPI (transactions, demande d'annulation, retour de fond, demande de paiement, QR Code, gestion des alias, gestion des abonnements, notifications, profil, compte) d [...]
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:bceao_pispi_app/bceao_pispi_app.dart';
/// Application hôte factice : simule une app participant déjà authentifiée,
/// qui pousse l'écran du SDK PI-SPI par-dessus la sienne.
///
/// **L'essentiel à lire pour intégrer le SDK dans une vraie app est la
/// méthode [FakeHostHomePage._openPiSpiSdk] ci-dessous** : c'est le seul
/// point d'entrée du SDK, tout le reste de ce fichier (widgets `_App*`,
/// `_StepUpAuthPage`, `_PinKeypad`) n'est que du décor pour cette démo.
///
/// Tourne en `demoMode: true` par défaut (aucune requête réelle, données
/// mockées depuis `assets/json/`) pour pouvoir visualiser le SDK sans
/// backend. Repassez `demoMode` à `false` et fournissez une vraie
/// `apiBaseUrl` + un vrai token pour tester contre un backend PI-SPI réel.
void main() {
runApp(const FakeHostApp());
}
class FakeHostApp extends StatelessWidget {
const FakeHostApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fake Host App',
debugShowCheckedModeBanner: false,
// Le SDK a besoin de son propre delegate de traductions pour que
// les textes de ses écrans s'affichent (fr/en/pt).
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
AppLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const FakeHostHomePage(),
);
}
}
/// Accueil de l'app hôte factice : une simple grille de trois scénarios de
/// démo, chacun ouvrant le SDK dans une configuration différente. Chaque
/// tuile de la grille ouvre en plus le SDK avec son propre thème de
/// démarrage (voir [_AppTileSpec.theme] et [PiSpiAppConfig.defaultTheme]).
///
/// - **Compte unique** → [_openPiSpiSdk] avec un seul [PiSpiCompte] : le SDK
/// saute l'écran de sélection de compte et le carrousel de l'accueil.
/// - **Comptes multiples** → [_openPiSpiSdk] avec plusieurs [PiSpiCompte] :
/// écran de sélection à l'ouverture, puis carrousel sur l'accueil du SDK.
/// - **Simuler un push** → [_openPiSpiSdkAndSimulatePush] : ouvre le SDK
/// normalement, puis simule 3s plus tard la réception d'un message push
/// (voir [_simulateIncomingPush] et `PiSpiPush.handleIncoming`).
class FakeHostHomePage extends StatelessWidget {
const FakeHostHomePage({super.key});
// Mode démo : aucune requête réelle n'est faite via l'API mockée
// (MockInterceptor), donc ni l'URL ni le token n'ont besoin d'être
// valides. Passez `demoMode: false` plus bas pour tester contre un vrai
// backend PI-SPI : il faudra alors renseigner ici une vraie apiBaseUrl et
// fournir un vrai token d'accès (voir accessToken dans _openPiSpiSdk).
static const _apiBaseUrl = 'https://sandbox.pi-bceao.com/api';
static const _fakeAccessToken = 'DEMO_MODE_TOKEN';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('App PSP')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chaque tuile de cette section ouvre le SDK avec un seul
// compte (pas de carrousel, pas d'écran de sélection) et son
// propre thème de démarrage (voir _AppTileSpec.theme et
// PiSpiAppConfig.defaultTheme).
_AppSection(
title: 'Le client a un seul compte',
onTapTile: (theme) => _openPiSpiSdk(context, theme: theme),
tiles: const [
_AppTileSpec(
'assets/pi_marron.png',
Color(0xFFF5F0E8),
),
_AppTileSpec(
'assets/pi_jaune.png',
Color(0xFF151413),
theme: Themes.dark,
),
_AppTileSpec(
'assets/pi_jaune.png',
Color(0xFFF5F0E8),
theme: Themes.yellow,
),
_AppTileSpec(
'assets/pi_marron.png',
Color(0xFF2E7D32),
theme: Themes.green,
),
_AppTileSpec(
'assets/pi_jaune.png',
Color(0xFF1565C0),
theme: Themes.blue,
),
_AppTileSpec(
'assets/pi_marron.png',
Color(0xFFC62828),
theme: Themes.red,
),
],
),
const SizedBox(height: 28),
// Chaque tuile de cette section ouvre le SDK avec plusieurs
// comptes (carrousel + écran de sélection à l'ouverture) et
// son propre thème de démarrage.
_AppSection(
title: 'Le client a plusieurs comptes',
onTapTile: (theme) => _openPiSpiSdk(
context,
multiCompte: true,
theme: theme,
),
tiles: const [
_AppTileSpec(
'assets/logo_maron_jaune.png',
Color(0xFFF5F0E8),
wide: true,
),
_AppTileSpec(
'assets/logo_blanc.png',
Color(0xFF151413),
theme: Themes.dark,
wide: true,
),
_AppTileSpec(
'assets/logo_maron_jaune.png',
Color(0xFFF5F0E8),
theme: Themes.light,
wide: true,
),
_AppTileSpec(
'assets/logo_jaune_maron.png',
Color(0xFFF5F0E8),
theme: Themes.yellow,
wide: true,
),
_AppTileSpec(
'assets/logo_maron_blanc.png',
Color(0xFF2E7D32),
theme: Themes.green,
wide: true,
),
_AppTileSpec(
'assets/logo_jaune_blanc.png',
Color(0xFF1565C0),
theme: Themes.blue,
wide: true,
),
_AppTileSpec(
'assets/logo_blanc.png',
Color(0xFFC62828),
theme: Themes.red,
wide: true,
),
],
),
const SizedBox(height: 28),
// Chaque tuile de cette section ouvre le SDK (avec son propre
// thème de démarrage) puis simule, trois secondes plus tard,
// la réception d'un push (voir PiSpiPush.handleIncoming).
_AppSection(
title: 'Simuler un push (transfert reçu)',
onTapTile: (theme) =>
_openPiSpiSdkAndSimulatePush(context, theme: theme),
tiles: const [
_AppTileSpec(
'assets/logo_jaune_blanc.png',
Color(0xFF1565C0),
theme: Themes.blue,
wide: true,
),
_AppTileSpec(
'assets/logo_blanc.png',
Color(0xFFC62828),
theme: Themes.red,
wide: true,
),
],
),
],
),
),
),
);
}
/// Point d'entrée du SDK : c'est cette fonction (et uniquement elle) que
/// vous devez adapter pour l'intégrer dans une vraie app.
///
/// Elle fait deux choses :
/// 1. Construit un [PiSpiSdkConfig] à partir des informations que l'app
/// hôte possède déjà à ce stade (l'utilisateur est censé être déjà
/// connecté chez le participant *avant* d'ouvrir le SDK — le SDK ne
/// gère lui-même aucune authentification, voir le README). Ici, tout
/// est codé en dur pour la démo ; dans une vraie app, remplacez ces
/// valeurs par celles de votre session utilisateur/backend réels
/// (profil du client, comptes bancaires, token d'accès en cours...).
/// 2. Pousse [PiSpiSdkEntry] comme une route normale de l'app hôte — le
/// SDK n'a pas son propre `MaterialApp`, il s'affiche simplement
/// par-dessus celui de l'hôte.
///
/// [multiCompte] et [theme] ne servent qu'à faire varier la démo (voir
/// [FakeHostHomePage]) : [multiCompte] bascule entre un
/// [PiSpiUserIdentity.comptes] à un seul élément ou à plusieurs, pour
/// illustrer les deux comportements du SDK (voir la section « Comptes
/// multiples » du README) ; [theme] fixe le thème de démarrage passé à
/// [PiSpiAppConfig.defaultTheme], différent pour chaque tuile de
/// l'accueil (voir [_AppTileSpec.theme]).
void _openPiSpiSdk(
BuildContext context, {
bool multiCompte = false,
Themes? theme,
}) {
final config = PiSpiSdkConfig(
// Identité du client déjà connecté côté hôte : voir le tableau
// PiSpiUserIdentity du README pour le détail de chaque champ. Dans une
// vraie app, ces valeurs viennent de votre propre backend/session, pas
// de constantes en dur comme ici.
user: PiSpiUserIdentity(
firstName: 'Jean',
lastName: 'Doe',
fullName: 'Jean Doe',
categorie: 'P',
nationalite: 'SN',
paysResidence: 'CI',
genre: '1',
// Pays de domiciliation du compte : doit être un code pays UEMOA
// (voir PiSpiUserIdentity.supportedPaysCodes).
pays: 'SN',
// comptes ne doit jamais être null ni vide. Avec un seul élément
// (cas "compte unique"), le SDK saute l'écran de sélection et le
// carrousel de l'accueil et utilise ce compte directement.
comptes: multiCompte
? const [
PiSpiCompte(
numero: 'SNB0000000000000000001',
libelle: 'Courant',
solde: 9240320,
),
PiSpiCompte(
numero: 'SNB0000000000000000002',
libelle: 'Chèque',
solde: 240000,
),
PiSpiCompte(
numero: 'SNB0000000000000000003',
libelle: 'Épargne',
solde: 32240320,
),
]
: const [
PiSpiCompte(
numero: 'SNB0000000000000000001',
libelle: 'Courant',
solde: 9240320,
),
],
),
// Connexion au backend PI-SPI : URL, authentification, mode démo.
api: PiSpiApiConfig(
apiBaseUrl: _apiBaseUrl,
// Token d'accès (Bearer) valide au moment de l'ouverture du SDK :
// c'est lui que le SDK ajoute à chaque requête HTTP faite vers
// [apiBaseUrl] (voir l'openapi du SDK, section Authentification).
// C'est le même token que celui utilisé par l'app hôte elle-même
// pour s'authentifier auprès de son propre backend — le SDK n'en
// obtient jamais un lui-même, il ne fait que le transmettre.
accessToken: _fakeAccessToken,
// Appelé si une requête échoue en 401 ou 403 et que le sdk détecte
// que accessToken a expiré : l'hôte rafraîchirait normalement son
// token ici et le retournerait. En démo, on renvoie toujours le
// même token factice (jamais rejeté par le mock).
onAccessTokenExpired: () async => _fakeAccessToken,
// Si le SDK ne parvient plus à obtenir de token valide (401
// persistant), on referme son écran et on revient à l'accueil.
onSessionExpired: () async {
final navigator = Navigator.of(context);
if (navigator.canPop()) navigator.pop();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session PI-SPI expirée')),
);
}
},
// Mode démo : aucune requête réelle n'est faite via l'API mockée
// Si demoMode est definie à true tout ce qui est appelle api dans le sdk sera mocké
// parconte s'il est definie à false les appelles api iront jusqu'au backend
demoMode: true,
),
// Identité et préférences de l'application hôte : participant PSP,
// langue, thème.
appConfig: PiSpiAppConfig(
// Code membre PSP du participant auprès de la BCEAO — le même pour
// tous les utilisateurs de cette app, à récupérer auprès de la
// BCEAO lors de votre intégration (pas une donnée utilisateur).
pspCodeMembre: 'SNB000',
// Le SDK n'a pas son propre sélecteur de langue : c'est l'hôte qui
// décide (et qui gère son propre paramétrage de langue, s'il en a
// un).
// le français est utilisé par defaut
defaultLocale: const Locale('fr'),
// Optionnel : appliqué et persisté comme thème par défaut à chaque
// ouverture (écrase même un choix fait par l'utilisateur dans les
// paramètres du SDK lors d'une session précédente). Ici, chaque
// tuile de l'accueil factice appelle _openPiSpiSdk avec un thème
// différent (voir _AppTileSpec.theme), pour illustrer ce
// comportement.
defaultTheme: theme,
),
// Politique de step-up avant chaque action sensible.
identification: PiSpiIdentificationConfig(
confirmationMethodLabel: 'external',
// Appelé par le SDK avant chaque action sensible (confirmation
// d'une transaction, suppression d'un alias/compte). Dans une
// vraie app, remplacez ce dialogue factice par un vrai écran
// PIN/biométrie (via `local_auth` par exemple) : le SDK ne voit
// jamais le secret, seulement le booléen retourné ici.
requireStepUpIdentification: () async {
if (!context.mounted) return false;
final confirmed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => const _StepUpAuthPage(),
),
);
return confirmed ?? false;
},
),
// Journalisation et remontée d'erreurs Flutter.
log: PiSpiLogConfig(
logLevel: "debug",
// Appelé pour chaque erreur Flutter interceptée pendant que le SDK
// est affiché. Dans une vraie app, transmettez `details` à votre
// propre outil de crash reporting (Crashlytics, Sentry...) ; ici on
// se contente de l'afficher dans les logs de la console.
onFlutterError: (details) {
debugPrint(
'[Host] Erreur Flutter remontée par le SDK : '
'${details.exceptionAsString()}',
);
},
),
// Fermeture volontaire (bouton retour/fermeture, compte clôturé...) :
// contrairement à onSessionExpired, ce n'est pas une erreur, donc pas
// de message "session expirée" ici — on referme juste l'écran.
onClose: () async {
final navigator = Navigator.of(context);
if (navigator.canPop()) navigator.pop();
},
);
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PiSpiSdkEntry(config: config),
));
}
void _openPiSpiSdkAndSimulatePush(
BuildContext context, {
Themes? theme ,
}) async {
// ouvrir d'abord le sdk
_openPiSpiSdk(context, theme: theme);
// attendre trois secondes (le temps que l'écran du SDK soit affiché)
// avant de simuler la réception du push
await Future.delayed(const Duration(seconds: 3));
if (context.mounted) _simulateIncomingPush(context);
}
/// Illustre PiSpiPush.handleIncoming : dans une vraie app, ce serait
/// appelé depuis le handler de message push de l'hôte (ex.
/// FirebaseMessaging.onMessage). Si l'écran du SDK est ouvert, ça
/// déclenche le même toast/rafraîchissement qu'un vrai push.
void _simulateIncomingPush(BuildContext context) {
PiSpiPush.handleIncoming(
{
'idObject': 'ESNB00120240105153554CancelpNvhUk9e',
'type': 'TRANSFERT_RECU',
'montant': '1500'
},
title: 'Transfert reçu',
body: 'Vous avez reçu 1 500 F CFA de Aïda Diop',
);
}
}
/// Description d'une tuile de la grille : image + couleur de fond, en
/// version carrée (icône seule) ou large (logo complet avec wordmark), et
/// le thème avec lequel cette tuile ouvre le SDK (voir
/// [PiSpiAppConfig.defaultTheme]).
class _AppTileSpec {
const _AppTileSpec(
this.asset,
this.background,{
this.wide = false,
this.theme
});
final String asset;
final Color background;
final Themes? theme;
final bool wide;
}
/// Section titrée de la grille (ex. "Compte unique") : toutes ses tuiles
/// déclenchent la même action au tap, mais chacune avec son propre thème
/// (voir [_AppTileSpec.theme]).
class _AppSection extends StatelessWidget {
const _AppSection({
required this.title,
required this.tiles,
required this.onTapTile,
});
final String title;
final List<_AppTileSpec> tiles;
final void Function(Themes? theme) onTapTile;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 14,
runSpacing: 14,
children: [
for (final tile in tiles)
_AppIconTile(
spec: tile,
onTap: () => onTapTile(tile.theme),
),
],
),
],
);
}
}
/// Une tuile façon icône d'app : fond coloré arrondi + image centrée,
/// libellé "PI-SPI" en dessous.
class _AppIconTile extends StatelessWidget {
const _AppIconTile({required this.spec, required this.onTap});
final _AppTileSpec spec;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final double width = spec.wide ? 140 : 72;
const double height = 72;
return InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Container(
width: width,
height: height,
padding: EdgeInsets.symmetric(
horizontal: spec.wide ? 12 : 14,
vertical: spec.wide ? 20 : 14,
),
decoration: BoxDecoration(
color: spec.background,
borderRadius: BorderRadius.circular(18),
),
child: Image.asset(spec.asset, fit: BoxFit.contain),
),
);
}
}
/// Page d'authentification factice affichée avant une action sensible.
/// Simule un scan biométrique (auto-validé après une courte animation)
/// avec un repli sur code PIN. Dans une vraie app, remplacez ce widget par
/// un vrai flux `local_auth` / saisie de code sécurisée : le SDK ne voit
/// jamais le secret, seulement le booléen retourné par cette page.
class _StepUpAuthPage extends StatefulWidget {
const _StepUpAuthPage();
@override
State<_StepUpAuthPage> createState() => _StepUpAuthPageState();
}
class _StepUpAuthPageState extends State<_StepUpAuthPage>
with SingleTickerProviderStateMixin {
static const int _pinLength = 4;
bool _showPinFallback = false;
bool _scanning = false;
final List<int> _pin = [];
void _startBiometricScan() async {
setState(() => _scanning = true);
// Simule le temps d'un scan biométrique réel.
await Future.delayed(const Duration(milliseconds: 1400));
if (mounted) Navigator.of(context).pop(true);
}
void _onDigitPressed(int digit) {
if (_pin.length >= _pinLength) return;
setState(() => _pin.add(digit));
if (_pin.length == _pinLength) {
// Simulation : n'importe quel code à 4 chiffres est accepté.
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) Navigator.of(context).pop(true);
});
}
}
void _onBackspace() {
if (_pin.isEmpty) return;
setState(() => _pin.removeLast());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.surface,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(false),
),
),
const Spacer(),
Text(
'Vérification requise',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
_showPinFallback
? 'Saisissez votre code PIN'
: 'Confirmez avec votre empreinte ou Face ID',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.outline),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
if (!_showPinFallback) ...[
GestureDetector(
onTap: _scanning ? null : _startBiometricScan,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _scanning
? theme.colorScheme.primary.withOpacity(0.15)
: theme.colorScheme.primaryContainer,
border: Border.all(
color: theme.colorScheme.primary,
width: 2,
),
),
child: _scanning
? const Padding(
padding: EdgeInsets.all(28),
child: CircularProgressIndicator(strokeWidth: 3),
)
: Icon(
Icons.fingerprint,
size: 64,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 24),
Text(
_scanning ? 'Scan en cours…' : 'Touchez pour scanner',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 32),
TextButton(
onPressed: () => setState(() => _showPinFallback = true),
child: const Text('Utiliser le code PIN à la place'),
),
] else ...[
// Indicateurs de saisie du code PIN.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_pinLength, (i) {
final filled = i < _pin.length;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
);
}),
),
const SizedBox(height: 32),
_PinKeypad(
onDigit: _onDigitPressed,
onBackspace: _onBackspace,
),
],
const Spacer(flex: 2),
],
),
),
),
);
}
}
/// Clavier numérique simple pour la saisie du code PIN factice.
class _PinKeypad extends StatelessWidget {
const _PinKeypad({required this.onDigit, required this.onBackspace});
final ValueChanged<int> onDigit;
final VoidCallback onBackspace;
@override
Widget build(BuildContext context) {
Widget buildKey(Widget child, VoidCallback? onTap) {
return InkWell(
borderRadius: BorderRadius.circular(36),
onTap: onTap,
child: SizedBox(
width: 72,
height: 72,
child: Center(child: child),
),
);
}
final rows = <List<Widget>>[
for (var r = 0; r < 3; r++)
[
for (var c = 1; c <= 3; c++)
buildKey(
Text('${r * 3 + c}', style: const TextStyle(fontSize: 22)),
() => onDigit(r * 3 + c),
),
],
[
const SizedBox(width: 72, height: 72),
buildKey(const Text('0', style: TextStyle(fontSize: 22)),
() => onDigit(0)),
buildKey(const Icon(Icons.backspace_outlined), onBackspace),
],
];
return Column(
children: [
for (final row in rows)
Row(mainAxisAlignment: MainAxisAlignment.center, children: row),
],
);
}
}