feat(onboarding): UI/UX polish + mapping typeOrg + gestion erreur paiement Wave

Plan selection :
- Grille 2×2 compacte pour les plages (au lieu de liste verticale)
- Badge  POPULAIRE sur STANDARD
- Remise annuelle affichée (−X%/an)
- AnimatedSwitcher + auto-scroll vers formules quand plage sélectionnée
- Dark mode adaptatif complet

Récapitulatif :
- Dark mode complet (AppColors pairs)
- Bloc Total gradient gold adaptatif
- NoteBox avec backgrounds accent.withOpacity()
- Utilise OnboardingBottomBar (consistence)

Payment method :
- Dark mode + couleurs de marque Wave/Orange hardcodées (intentionnel)
- Logo container reste blanc (brand)
- Mapping typeOrganisation détaillé → enum backend ASSOCIATION/MUTUELLE/
  COOPERATIVE/FEDERATION (fix HTTP 400 'Valeur invalide pour typeOrganisation')

Wave payment :
- Dark mode adaptatif
- Message dev clair (simulation automatique)
- Gestion OnboardingPaiementEchoue : SnackBar rouge + reset flags + reste sur page
  (plus de faux succès quand confirmerPaiement() return false ou lève exception)

Bloc : nouvel état OnboardingPaiementEchoue, _onRetourDepuisWave vérifie le return
de confirmerPaiement() (plus de catch silencieux qui émettait OnboardingPaiementConfirme)

Shared widgets : OnboardingSectionTitle + OnboardingBottomBar dark mode + hint optionnel
This commit is contained in:
dahoud
2026-04-15 20:14:27 +00:00
parent 36a903c80e
commit 21b519de53
8 changed files with 1081 additions and 859 deletions

View File

@@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../bloc/onboarding_bloc.dart';
import '../../data/models/souscription_status_model.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../../../core/config/environment.dart';
/// Étape 4 — Lancement du paiement Wave + attente du retour
@@ -33,6 +34,9 @@ class _WavePaymentPageState extends State<WavePaymentPage>
widget.waveLaunchUrl.contains('localhost') ||
!AppConfig.isProd;
// Couleur de marque Wave (volontairement hardcodée)
static const _waveBlue = Color(0xFF00B9F1);
@override
void initState() {
super.initState();
@@ -72,10 +76,10 @@ class _WavePaymentPageState extends State<WavePaymentPage>
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
SnackBar(
content: const Text(
'Impossible d\'ouvrir Wave. Vérifiez que l\'application est installée.'),
backgroundColor: UnionFlowColors.error,
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
),
);
@@ -85,336 +89,354 @@ class _WavePaymentPageState extends State<WavePaymentPage>
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final bgSurface = isDark ? AppColors.surfaceDark : AppColors.surface;
final borderColor = isDark ? AppColors.borderDark : AppColors.border;
final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary;
final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondary;
final montant = widget.souscription.montantTotal ?? 0;
const waveBlue = Color(0xFF00B9F1);
return Scaffold(
backgroundColor: UnionFlowColors.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Row(
children: [
if (!_paymentLaunched && !_simulating)
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.arrow_back_rounded),
color: UnionFlowColors.textSecondary,
),
const Spacer(),
if (_isMock)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: UnionFlowColors.warningPale,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: UnionFlowColors.warning.withOpacity(0.4)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.science_rounded,
size: 13, color: UnionFlowColors.warning),
SizedBox(width: 4),
Text(
'Mode dev',
style: TextStyle(
color: UnionFlowColors.warning,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
],
),
)
else
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: waveBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Wave Mobile Money',
style: TextStyle(
color: waveBlue,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
return BlocListener<OnboardingBloc, OnboardingState>(
listener: (context, state) {
// Afficher snackbar si la confirmation a échoué
if (state is OnboardingPaiementEchoue) {
setState(() {
_paymentLaunched = false;
_appResumed = false;
_simulating = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(children: [
const Icon(Icons.error_outline, color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(state.message)),
]),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
),
);
}
},
child: Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// ── Top bar ───────────────────────────────────
Row(
children: [
if (_simulating) ...[
// Animation de simulation
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF00B9F1), Color(0xFF0096C7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: waveBlue.withOpacity(0.35),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: const Center(
child: SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
),
if (!_paymentLaunched && !_simulating)
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.arrow_back_rounded),
color: textSecondary,
),
const SizedBox(height: 28),
const Text(
'Simulation du paiement…',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Confirmation en cours',
style: TextStyle(
color: UnionFlowColors.textSecondary, fontSize: 14),
),
] else ...[
// Logo Wave
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: UnionFlowColors.border),
boxShadow: [
BoxShadow(
color: waveBlue.withOpacity(0.2),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
padding: const EdgeInsets.all(16),
child: Image.asset(
'assets/images/payment_methods/wave/logo.png',
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.waves_rounded,
color: waveBlue,
size: 52,
),
),
),
const SizedBox(height: 28),
Text(
_paymentLaunched
? 'Paiement en cours…'
: _isMock
? 'Simuler le paiement'
: 'Prêt à payer',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: UnionFlowColors.textPrimary,
),
),
const SizedBox(height: 8),
// Montant
RichText(
text: TextSpan(
children: [
TextSpan(
text: '${_formatPrix(montant)} ',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w900,
color: waveBlue,
letterSpacing: -0.5,
),
),
const TextSpan(
text: 'FCFA',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textSecondary,
),
),
],
),
),
if (widget.souscription.organisationNom != null) ...[
const SizedBox(height: 4),
Text(
widget.souscription.organisationNom!,
style: const TextStyle(
color: UnionFlowColors.textSecondary,
fontSize: 13),
),
],
const SizedBox(height: 32),
if (!_paymentLaunched) ...[
if (_isMock)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: UnionFlowColors.warningPale,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color:
UnionFlowColors.warning.withOpacity(0.3)),
),
child: const Row(
children: [
Icon(Icons.science_outlined,
color: UnionFlowColors.warning, size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
'Environnement de développement — le paiement sera simulé automatiquement.',
style: TextStyle(
fontSize: 12,
color: UnionFlowColors.warning,
height: 1.4),
),
),
],
),
),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _lancerOuSimuler,
icon: Icon(_isMock
? Icons.play_circle_rounded
: Icons.open_in_new_rounded),
label: Text(
_isMock
? 'Simuler le paiement Wave'
: 'Ouvrir Wave',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
),
style: ElevatedButton.styleFrom(
backgroundColor: waveBlue,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
shadowColor: waveBlue.withOpacity(0.4),
elevation: 3,
),
),
),
] else ...[
// Paiement lancé en prod
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
),
child: Column(
children: [
const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
color: waveBlue,
strokeWidth: 3,
),
),
const SizedBox(height: 16),
const Text(
'Paiement en cours dans Wave',
style: TextStyle(
fontWeight: FontWeight.w700,
color: UnionFlowColors.textPrimary,
),
),
const SizedBox(height: 6),
const Text(
'Revenez dans l\'app une fois\nvotre paiement confirmé.',
textAlign: TextAlign.center,
style: TextStyle(
color: UnionFlowColors.textSecondary,
fontSize: 13,
height: 1.4),
),
],
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => context
.read<OnboardingBloc>()
.add(const OnboardingRetourDepuisWave()),
icon: const Icon(
Icons.check_circle_outline_rounded),
label: const Text(
'J\'ai effectué le paiement',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700),
),
style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
),
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: _lancerOuSimuler,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Rouvrir Wave'),
style: TextButton.styleFrom(
foregroundColor: waveBlue),
),
],
],
const Spacer(),
if (_isMock)
_buildDevBadge()
else
_buildWaveBadge(),
],
),
// ── Contenu principal ─────────────────────────
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_simulating)
_buildSimulatingView(textPrimary, textSecondary)
else
_buildPaymentView(
montant: montant,
bgSurface: bgSurface,
borderColor: borderColor,
textPrimary: textPrimary,
textSecondary: textSecondary,
isDark: isDark,
),
],
),
),
],
),
),
),
),
);
}
// ─── Badges ────────────────────────────────────────────────
Widget _buildDevBadge() => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.warning.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.warning.withOpacity(0.4)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.science_rounded, size: 13, color: AppColors.warning),
SizedBox(width: 4),
Text(
'Mode dev',
style: TextStyle(
color: AppColors.warning,
fontSize: 11,
fontWeight: FontWeight.w700),
),
],
),
);
Widget _buildWaveBadge() => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: _waveBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Wave Mobile Money',
style: TextStyle(
color: _waveBlue, fontSize: 12, fontWeight: FontWeight.w700),
),
);
// ─── Vue simulation ───────────────────────────────────────
Widget _buildSimulatingView(Color textPrimary, Color textSecondary) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF00B9F1), Color(0xFF0096C7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: _waveBlue.withOpacity(0.35),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: const Center(
child: SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
),
),
),
const SizedBox(height: 28),
Text(
'Simulation du paiement…',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: textPrimary),
),
const SizedBox(height: 8),
Text(
'Confirmation en cours auprès du serveur',
style: TextStyle(color: textSecondary, fontSize: 14),
),
],
);
}
// ─── Vue paiement ─────────────────────────────────────────
Widget _buildPaymentView({
required double montant,
required Color bgSurface,
required Color borderColor,
required Color textPrimary,
required Color textSecondary,
required bool isDark,
}) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo Wave — fond blanc intentionnel (brand)
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: borderColor),
boxShadow: [
BoxShadow(
color: _waveBlue.withOpacity(0.2),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
padding: const EdgeInsets.all(16),
child: Image.asset(
'assets/images/payment_methods/wave/logo.png',
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(Icons.waves_rounded, color: _waveBlue, size: 52),
),
),
const SizedBox(height: 28),
Text(
_paymentLaunched
? 'Paiement en cours…'
: _isMock ? 'Simuler le paiement' : 'Prêt à payer',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: textPrimary),
),
const SizedBox(height: 8),
// Montant
RichText(
text: TextSpan(
children: [
TextSpan(
text: '${_formatPrix(montant)} ',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w900,
color: _waveBlue,
letterSpacing: -0.5,
),
),
TextSpan(
text: 'FCFA',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700, color: textSecondary),
),
],
),
),
),
if (widget.souscription.organisationNom != null) ...[
const SizedBox(height: 4),
Text(
widget.souscription.organisationNom!,
style: TextStyle(color: textSecondary, fontSize: 13),
),
],
const SizedBox(height: 32),
if (!_paymentLaunched) ...[
if (_isMock)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warning.withOpacity(isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.warning.withOpacity(0.3)),
),
child: const Row(
children: [
Icon(Icons.science_outlined, color: AppColors.warning, size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
'Environnement de développement — le paiement sera simulé automatiquement. En production, vous serez redirigé vers l\'application Wave.',
style: TextStyle(
fontSize: 12, color: AppColors.warning, height: 1.4),
),
),
],
),
),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _lancerOuSimuler,
icon: Icon(_isMock
? Icons.play_circle_rounded
: Icons.open_in_new_rounded),
label: Text(
_isMock ? 'Simuler le paiement Wave' : 'Ouvrir Wave',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
style: ElevatedButton.styleFrom(
backgroundColor: _waveBlue,
foregroundColor: AppColors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
shadowColor: _waveBlue.withOpacity(0.4),
elevation: 3,
),
),
),
] else ...[
// Paiement lancé en prod
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: bgSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: borderColor),
boxShadow: isDark ? null : UnionFlowColors.softShadow,
),
child: Column(
children: [
const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(color: _waveBlue, strokeWidth: 3),
),
const SizedBox(height: 16),
Text(
'Paiement en cours dans Wave',
style: TextStyle(fontWeight: FontWeight.w700, color: textPrimary),
),
const SizedBox(height: 6),
Text(
'Revenez dans l\'app une fois\nvotre paiement confirmé.',
textAlign: TextAlign.center,
style: TextStyle(
color: textSecondary, fontSize: 13, height: 1.4),
),
],
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => context
.read<OnboardingBloc>()
.add(const OnboardingRetourDepuisWave()),
icon: const Icon(Icons.check_circle_outline_rounded),
label: const Text(
'J\'ai effectué le paiement',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: AppColors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: _lancerOuSimuler,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Rouvrir Wave'),
style: TextButton.styleFrom(foregroundColor: _waveBlue),
),
],
],
);
}