From 21b519de53097c93b83e03e9195f34c90797ef6b Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:14:27 +0000 Subject: [PATCH] feat(onboarding): UI/UX polish + mapping typeOrg + gestion erreur paiement Wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../onboarding/bloc/onboarding_bloc.dart | 44 +- .../datasources/souscription_datasource.dart | 42 +- .../pages/onboarding_flow_page.dart | 19 +- .../pages/onboarding_shared_widgets.dart | 121 ++-- .../pages/payment_method_page.dart | 208 +++--- .../pages/plan_selection_page.dart | 501 ++++++++----- .../pages/subscription_summary_page.dart | 337 ++++----- .../presentation/pages/wave_payment_page.dart | 668 +++++++++--------- 8 files changed, 1081 insertions(+), 859 deletions(-) diff --git a/lib/features/onboarding/bloc/onboarding_bloc.dart b/lib/features/onboarding/bloc/onboarding_bloc.dart index 07dd9dc..949f29c 100644 --- a/lib/features/onboarding/bloc/onboarding_bloc.dart +++ b/lib/features/onboarding/bloc/onboarding_bloc.dart @@ -141,6 +141,20 @@ class OnboardingStepPaiement extends OnboardingState { /// Paiement confirmé — déclenche un re-check du statut du compte class OnboardingPaiementConfirme extends OnboardingState {} +/// Erreur de confirmation paiement — l'utilisateur peut réessayer +class OnboardingPaiementEchoue extends OnboardingState { + final String message; + final SouscriptionStatusModel souscription; + final String waveLaunchUrl; + const OnboardingPaiementEchoue({ + required this.message, + required this.souscription, + required this.waveLaunchUrl, + }); + @override + List get props => [message, souscription, waveLaunchUrl]; +} + /// Étape 5 : en attente de validation SuperAdmin class OnboardingStepAttente extends OnboardingState { final SouscriptionStatusModel? souscription; @@ -316,16 +330,32 @@ class OnboardingBloc extends Bloc { Future _onRetourDepuisWave( OnboardingRetourDepuisWave event, Emitter emit) async { emit(OnboardingLoading()); + final souscId = _souscription?.souscriptionId; + final waveUrl = _souscription?.waveLaunchUrl; + + if (souscId == null) { + emit(const OnboardingError('Souscription introuvable.')); + return; + } + try { - final souscId = _souscription?.souscriptionId; - if (souscId != null) { - await _datasource.confirmerPaiement(souscId); + final success = await _datasource.confirmerPaiement(souscId); + if (success) { + emit(OnboardingPaiementConfirme()); + } else { + // La confirmation a échoué côté backend — l'utilisateur doit réessayer + emit(OnboardingPaiementEchoue( + message: 'Le paiement n\'a pas pu être confirmé. Vérifiez que le paiement Wave a bien été effectué et réessayez.', + souscription: _souscription!, + waveLaunchUrl: waveUrl ?? '', + )); } - // Émettre OnboardingPaiementConfirme pour déclencher re-check du compte - // Si le backend auto-active le compte, AuthStatusChecked redirigera vers dashboard - emit(OnboardingPaiementConfirme()); } catch (e) { - emit(OnboardingPaiementConfirme()); + emit(OnboardingPaiementEchoue( + message: 'Erreur lors de la confirmation: ${e.toString().replaceFirst("Exception: ", "")}', + souscription: _souscription!, + waveLaunchUrl: waveUrl ?? '', + )); } } } diff --git a/lib/features/onboarding/data/datasources/souscription_datasource.dart b/lib/features/onboarding/data/datasources/souscription_datasource.dart index 2a509e2..cddf03f 100644 --- a/lib/features/onboarding/data/datasources/souscription_datasource.dart +++ b/lib/features/onboarding/data/datasources/souscription_datasource.dart @@ -53,6 +53,43 @@ class SouscriptionDatasource { return null; } + /// Mappe le type d'organisation détaillé (ex: MUTUELLE_EPARGNE, CLUB_SPORTIF) + /// vers l'une des 4 catégories acceptées par le backend souscription : + /// ASSOCIATION, MUTUELLE, COOPERATIVE, FEDERATION. + static String? _mapTypeOrganisationBilling(String? detailedType) { + if (detailedType == null || detailedType.isEmpty) return null; + switch (detailedType.toUpperCase()) { + // Catégorie ASSOCIATIF + RELIGIEUX + case 'ASSOCIATION': + case 'CLUB_SERVICE': + case 'CLUB_SPORTIF': + case 'CLUB_CULTUREL': + case 'EGLISE': + case 'GROUPE_PRIERE': + return 'ASSOCIATION'; + // Catégorie FINANCIER_SOLIDAIRE (épargne/crédit) + case 'TONTINE': + case 'MUTUELLE_EPARGNE': + case 'MUTUELLE_CREDIT': + case 'MUTUELLE': + return 'MUTUELLE'; + // Catégorie COOPERATIVE + case 'COOPERATIVE': + case 'GIE': + return 'COOPERATIVE'; + // Catégorie PROFESSIONNEL + RESEAU_FEDERATION + case 'ONG': + case 'FONDATION': + case 'SYNDICAT': + case 'ORDRE_PROFESSIONNEL': + case 'FEDERATION': + case 'RESEAU': + return 'FEDERATION'; + default: + return 'ASSOCIATION'; // fallback sûr + } + } + /// Crée une demande de souscription Future creerDemande({ required String typeFormule, @@ -63,14 +100,15 @@ class SouscriptionDatasource { }) async { try { final opts = await _authOptions(); + final mappedType = _mapTypeOrganisationBilling(typeOrganisation); final body = { 'typeFormule': typeFormule, 'plageMembres': plageMembres, 'typePeriode': typePeriode, 'organisationId': organisationId, }; - if (typeOrganisation != null && typeOrganisation.isNotEmpty) { - body['typeOrganisation'] = typeOrganisation; + if (mappedType != null && mappedType.isNotEmpty) { + body['typeOrganisation'] = mappedType; } final response = await _dio.post( '$_base/api/souscriptions/demande', diff --git a/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart b/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart index c0830b8..2f47216 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_flow_page.dart @@ -4,6 +4,7 @@ import '../../bloc/onboarding_bloc.dart'; import '../../../../core/di/injection.dart'; import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import 'plan_selection_page.dart'; import 'period_selection_page.dart'; import 'subscription_summary_page.dart'; @@ -57,7 +58,7 @@ class _OnboardingFlowView extends StatelessWidget { builder: (context, state) { if (state is OnboardingLoading || state is OnboardingInitial || state is OnboardingPaiementConfirme) { return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -83,7 +84,7 @@ class _OnboardingFlowView extends StatelessWidget { if (state is OnboardingError) { return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Center( child: Padding( padding: const EdgeInsets.all(32), @@ -115,7 +116,7 @@ class _OnboardingFlowView extends StatelessWidget { ), style: ElevatedButton.styleFrom( backgroundColor: UnionFlowColors.unionGreen, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, ), child: const Text('Réessayer'), ), @@ -153,6 +154,14 @@ class _OnboardingFlowView extends StatelessWidget { ); } + // Échec de confirmation — retourne à la page Wave avec le SnackBar d'erreur + if (state is OnboardingPaiementEchoue) { + return WavePaymentPage( + souscription: state.souscription, + waveLaunchUrl: state.waveLaunchUrl, + ); + } + if (state is OnboardingStepAttente) { return AwaitingValidationPage(souscription: state.souscription); } @@ -176,7 +185,7 @@ class _RejectedPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SafeArea( child: Padding( padding: const EdgeInsets.all(32), @@ -247,7 +256,7 @@ class _RejectedPage extends StatelessWidget { ), style: ElevatedButton.styleFrom( backgroundColor: UnionFlowColors.unionGreen, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12)), diff --git a/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart b/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart index af42dc9..6eaa869 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_shared_widgets.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart'; /// Header commun à toutes les étapes d'onboarding @@ -19,9 +20,7 @@ class OnboardingStepHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - decoration: const BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - ), + decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient), child: SafeArea( bottom: false, child: Padding( @@ -29,6 +28,7 @@ class OnboardingStepHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Barre de progression segmentée Row( children: List.generate(total, (i) { final done = i < step; @@ -37,9 +37,7 @@ class OnboardingStepHeader extends StatelessWidget { height: 4, margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), decoration: BoxDecoration( - color: done - ? Colors.white - : Colors.white.withOpacity(0.3), + color: done ? Colors.white : Colors.white.withOpacity(0.3), borderRadius: BorderRadius.circular(2), ), ), @@ -81,88 +79,129 @@ class OnboardingStepHeader extends StatelessWidget { } } -/// Titre de section avec icône +/// Titre de section avec icône — dark/light adaptatif class OnboardingSectionTitle extends StatelessWidget { final IconData icon; final String title; + final String? badge; // Ex: "Étape 1 complète ✓" const OnboardingSectionTitle({ super.key, required this.icon, required this.title, + this.badge, }); @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textColor = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary; + return Row( children: [ Icon(icon, color: UnionFlowColors.unionGreen, size: 20), const SizedBox(width: 8), - Text( - title, - style: const TextStyle( - color: UnionFlowColors.textPrimary, - fontWeight: FontWeight.w700, - fontSize: 16, + Expanded( + child: Text( + title, + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w700, + fontSize: 16, + ), ), ), + if (badge != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: UnionFlowColors.unionGreen.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + badge!, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: UnionFlowColors.unionGreen, + ), + ), + ), ], ); } } -/// Barre de bouton principale en bas de page +/// Barre de bouton principale en bas de page — dark/light adaptatif class OnboardingBottomBar extends StatelessWidget { final bool enabled; final String label; final VoidCallback onPressed; + final String? hint; // Texte optionnel au-dessus du bouton const OnboardingBottomBar({ super.key, required this.enabled, required this.label, required this.onPressed, + this.hint, }); @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? AppColors.surfaceDark : AppColors.surface; + final borderColor = isDark ? AppColors.borderDark : AppColors.border; + final hintColor = isDark ? AppColors.textSecondaryDark : AppColors.textSecondary; + return Container( padding: EdgeInsets.fromLTRB( 20, 12, 20, MediaQuery.of(context).padding.bottom + 12), decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: bgColor, + border: Border(top: BorderSide(color: borderColor, width: 0.5)), boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 12, - offset: const Offset(0, -4), - ), + BoxShadow(color: AppColors.shadow, blurRadius: 12, offset: const Offset(0, -4)), ], ), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: enabled ? onPressed : null, - style: ElevatedButton.styleFrom( - backgroundColor: UnionFlowColors.unionGreen, - disabledBackgroundColor: UnionFlowColors.border, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hint != null) ...[ + Text( + hint!, + style: TextStyle(fontSize: 11, color: hintColor), + textAlign: TextAlign.center, ), - elevation: enabled ? 2 : 0, - shadowColor: UnionFlowColors.unionGreen.withOpacity(0.4), - ), - child: Text( - label, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.3, + const SizedBox(height: 8), + ], + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: enabled ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: UnionFlowColors.unionGreen, + disabledBackgroundColor: isDark ? AppColors.borderDark : AppColors.border, + foregroundColor: AppColors.onPrimary, + disabledForegroundColor: isDark + ? AppColors.textSecondaryDark + : AppColors.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: enabled ? 2 : 0, + shadowColor: UnionFlowColors.unionGreen.withOpacity(0.4), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + ), ), ), - ), + ], ), ); } diff --git a/lib/features/onboarding/presentation/pages/payment_method_page.dart b/lib/features/onboarding/presentation/pages/payment_method_page.dart index dfa7e92..454571d 100644 --- a/lib/features/onboarding/presentation/pages/payment_method_page.dart +++ b/lib/features/onboarding/presentation/pages/payment_method_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/souscription_status_model.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart'; /// Écran de sélection du moyen de paiement @@ -17,13 +18,17 @@ class PaymentMethodPage extends StatefulWidget { class _PaymentMethodPageState extends State { String? _selected; + // Couleurs de marque Wave et Orange Money — volontairement hardcodées + static const _waveBlue = Color(0xFF00B9F1); + static const _orangeOrange = Color(0xFFFF6600); + static const _methods = [ _PayMethod( id: 'WAVE', name: 'Wave Mobile Money', description: 'Paiement rapide via votre compte Wave', logoAsset: 'assets/images/payment_methods/wave/logo.png', - color: Color(0xFF00B9F1), + color: _waveBlue, available: true, badge: 'Recommandé', ), @@ -32,7 +37,7 @@ class _PaymentMethodPageState extends State { name: 'Orange Money', description: 'Paiement via Orange Money', logoAsset: 'assets/images/payment_methods/orange_money/logo-black.png', - color: Color(0xFFFF6600), + color: _orangeOrange, available: false, badge: 'Prochainement', ), @@ -40,17 +45,20 @@ class _PaymentMethodPageState extends State { @override Widget build(BuildContext context) { - final montant = widget.souscription.montantTotal ?? 0; + final montant = widget.souscription.montantTotal ?? 0; + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgCard = 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; return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ - // Header + // Header gradient — toujours sombre, texte blanc intentionnel Container( - decoration: const BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - ), + decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient), child: SafeArea( bottom: false, child: Padding( @@ -60,25 +68,19 @@ class _PaymentMethodPageState extends State { children: [ IconButton( onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon(Icons.arrow_back_rounded, - color: Colors.white), + icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), const SizedBox(height: 12), const Text( 'Moyen de paiement', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.w800, - ), + style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800), ), const SizedBox(height: 4), Text( 'Choisissez comment régler votre souscription', - style: TextStyle( - color: Colors.white.withOpacity(0.8), fontSize: 13), + style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 13), ), ], ), @@ -92,13 +94,14 @@ class _PaymentMethodPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Montant rappel + // Rappel montant Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: bgCard, borderRadius: BorderRadius.circular(14), - boxShadow: UnionFlowColors.softShadow, + border: Border.all(color: borderColor), + boxShadow: isDark ? null : UnionFlowColors.softShadow, ), child: Row( children: [ @@ -109,24 +112,19 @@ class _PaymentMethodPageState extends State { gradient: UnionFlowColors.goldGradient, borderRadius: BorderRadius.circular(10), ), - child: const Icon(Icons.receipt_rounded, - color: Colors.white, size: 22), + child: const Icon(Icons.receipt_rounded, color: Colors.white, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Montant total', - style: TextStyle( - color: UnionFlowColors.textSecondary, - fontSize: 12), - ), + Text('Montant total', + style: TextStyle(color: textSecondary, fontSize: 12)), Text( '${_formatPrix(montant)} FCFA', - style: const TextStyle( - color: UnionFlowColors.textPrimary, + style: TextStyle( + color: textPrimary, fontSize: 20, fontWeight: FontWeight.w900, ), @@ -138,18 +136,12 @@ class _PaymentMethodPageState extends State { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Text( - 'Organisation', - style: TextStyle( - color: UnionFlowColors.textSecondary, - fontSize: 11), - ), + Text('Organisation', + style: TextStyle(color: textSecondary, fontSize: 11)), Text( widget.souscription.organisationNom!, - style: const TextStyle( - color: UnionFlowColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w600), + style: TextStyle( + color: textPrimary, fontSize: 12, fontWeight: FontWeight.w600), ), ], ), @@ -158,10 +150,10 @@ class _PaymentMethodPageState extends State { ), const SizedBox(height: 24), - const Text( + Text( 'Sélectionnez un moyen de paiement', style: TextStyle( - color: UnionFlowColors.textPrimary, + color: textPrimary, fontWeight: FontWeight.w700, fontSize: 15, ), @@ -169,36 +161,33 @@ class _PaymentMethodPageState extends State { const SizedBox(height: 12), ..._methods.map((m) => _MethodCard( - method: m, - selected: _selected == m.id, - onTap: m.available - ? () => setState(() => _selected = m.id) - : null, - )), + method: m, + selected: _selected == m.id, + onTap: m.available ? () => setState(() => _selected = m.id) : null, + )), const SizedBox(height: 20), + + // Bandeau sécurité — fond vert pâle adaptatif Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: UnionFlowColors.unionGreenPale, + color: isDark + ? UnionFlowColors.unionGreen.withOpacity(0.1) + : UnionFlowColors.unionGreenPale, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: - UnionFlowColors.unionGreen.withOpacity(0.25)), + border: Border.all(color: UnionFlowColors.unionGreen.withOpacity(0.25)), ), child: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.lock_rounded, - color: UnionFlowColors.unionGreen, size: 18), + Icon(Icons.lock_rounded, color: UnionFlowColors.unionGreen, size: 18), SizedBox(width: 10), Expanded( child: Text( 'Vos informations de paiement sont sécurisées et ne sont jamais stockées sur nos serveurs. La transaction est traitée directement par Wave.', style: TextStyle( - fontSize: 12, - color: UnionFlowColors.unionGreen, - height: 1.4), + fontSize: 12, color: UnionFlowColors.unionGreen, height: 1.4), ), ), ], @@ -214,41 +203,34 @@ class _PaymentMethodPageState extends State { padding: EdgeInsets.fromLTRB( 20, 12, 20, MediaQuery.of(context).padding.bottom + 12), decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: bgCard, + border: Border(top: BorderSide(color: borderColor)), boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 12, - offset: const Offset(0, -4), - ), + BoxShadow(color: AppColors.shadow, blurRadius: 12, offset: const Offset(0, -4)), ], ), child: SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: _selected == 'WAVE' - ? () => context - .read() - .add(const OnboardingPaiementInitie()) + ? () => context.read().add(const OnboardingPaiementInitie()) : null, icon: const Icon(Icons.open_in_new_rounded), label: Text( _selected == 'WAVE' ? 'Payer avec Wave' : 'Sélectionnez un moyen de paiement', - style: - const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00B9F1), - disabledBackgroundColor: UnionFlowColors.border, - foregroundColor: Colors.white, - disabledForegroundColor: UnionFlowColors.textSecondary, + backgroundColor: _waveBlue, + disabledBackgroundColor: isDark ? AppColors.borderDark : AppColors.border, + foregroundColor: AppColors.onPrimary, + disabledForegroundColor: isDark ? AppColors.textSecondaryDark : AppColors.textSecondary, padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), elevation: _selected == 'WAVE' ? 3 : 0, - shadowColor: const Color(0xFF00B9F1).withOpacity(0.4), + shadowColor: _waveBlue.withOpacity(0.4), ), ), ), @@ -259,9 +241,7 @@ class _PaymentMethodPageState extends State { String _formatPrix(double prix) { if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; final s = prix.toStringAsFixed(0); - if (s.length > 3) { - return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; - } + if (s.length > 3) return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; return s; } } @@ -287,15 +267,18 @@ class _MethodCard extends StatelessWidget { final bool selected; final VoidCallback? onTap; - const _MethodCard({ - required this.method, - required this.selected, - this.onTap, - }); + const _MethodCard({required this.method, required this.selected, this.onTap}); @override Widget build(BuildContext context) { - final disabled = onTap == null; + final disabled = onTap == null; + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgSurface = isDark ? AppColors.surfaceDark : AppColors.surface; + final bgDisabled = isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariant; + final borderDefault = isDark ? AppColors.borderDark : AppColors.border; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary; + final textTertiary = isDark ? AppColors.textSecondaryDark : AppColors.textTertiary; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondary; return GestureDetector( onTap: onTap, @@ -304,51 +287,43 @@ class _MethodCard extends StatelessWidget { margin: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: disabled - ? UnionFlowColors.surfaceVariant + ? bgDisabled : selected - ? method.color.withOpacity(0.06) - : UnionFlowColors.surface, + ? method.color.withOpacity(isDark ? 0.12 : 0.06) + : bgSurface, border: Border.all( - color: selected ? method.color : UnionFlowColors.border, + color: selected ? method.color : borderDefault, width: selected ? 2 : 1, ), borderRadius: BorderRadius.circular(14), boxShadow: disabled ? [] : selected - ? [ - BoxShadow( - color: method.color.withOpacity(0.15), - blurRadius: 16, - offset: const Offset(0, 6), - ) - ] - : UnionFlowColors.softShadow, + ? [BoxShadow(color: method.color.withOpacity(0.15), blurRadius: 16, offset: const Offset(0, 6))] + : isDark ? null : UnionFlowColors.softShadow, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ - // Logo image + // Logo — fond blanc intentionnel (logos de marque) Container( width: 56, height: 48, decoration: BoxDecoration( - color: disabled - ? UnionFlowColors.border - : Colors.white, + color: disabled ? bgDisabled : Colors.white, borderRadius: BorderRadius.circular(10), - border: Border.all(color: UnionFlowColors.border), + border: Border.all(color: borderDefault), ), padding: const EdgeInsets.all(6), child: Image.asset( method.logoAsset, fit: BoxFit.contain, - color: disabled ? UnionFlowColors.textTertiary : null, + color: disabled ? textTertiary : null, colorBlendMode: disabled ? BlendMode.srcIn : null, errorBuilder: (_, __, ___) => Icon( Icons.account_balance_wallet_rounded, - color: disabled ? UnionFlowColors.textTertiary : method.color, + color: disabled ? textTertiary : method.color, size: 24, ), ), @@ -363,18 +338,14 @@ class _MethodCard extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.w700, fontSize: 14, - color: disabled - ? UnionFlowColors.textTertiary - : UnionFlowColors.textPrimary, + color: disabled ? textTertiary : textPrimary, ), ), Text( method.description, style: TextStyle( fontSize: 12, - color: disabled - ? UnionFlowColors.textTertiary - : UnionFlowColors.textSecondary, + color: disabled ? textTertiary : textSecondary, ), ), ], @@ -384,14 +355,13 @@ class _MethodCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: disabled - ? UnionFlowColors.border + ? borderDefault : method.available - ? method.color.withOpacity(0.1) - : UnionFlowColors.surfaceVariant, + ? method.color.withOpacity(isDark ? 0.2 : 0.1) + : isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariant, borderRadius: BorderRadius.circular(20), ), child: Text( @@ -400,20 +370,18 @@ class _MethodCard extends StatelessWidget { fontSize: 10, fontWeight: FontWeight.w700, color: disabled - ? UnionFlowColors.textTertiary + ? textTertiary : method.available ? method.color - : UnionFlowColors.textSecondary, + : textSecondary, ), ), ), if (!disabled) ...[ const SizedBox(height: 6), Icon( - selected - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked, - color: selected ? method.color : UnionFlowColors.border, + selected ? Icons.check_circle_rounded : Icons.radio_button_unchecked, + color: selected ? method.color : borderDefault, size: 20, ), ], diff --git a/lib/features/onboarding/presentation/pages/plan_selection_page.dart b/lib/features/onboarding/presentation/pages/plan_selection_page.dart index 9b654e9..a6ad880 100644 --- a/lib/features/onboarding/presentation/pages/plan_selection_page.dart +++ b/lib/features/onboarding/presentation/pages/plan_selection_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/formule_model.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import 'onboarding_shared_widgets.dart'; @@ -17,30 +18,31 @@ class PlanSelectionPage extends StatefulWidget { class _PlanSelectionPageState extends State { String? _selectedPlage; String? _selectedFormule; + final _scrollController = ScrollController(); static const _plages = [ - _Plage('PETITE', 'Petite', '1 – 100 membres', Icons.group_outlined, 'Associations naissantes et petites structures'), - _Plage('MOYENNE', 'Moyenne', '101 – 500 membres', Icons.groups_outlined, 'Associations établies en croissance'), - _Plage('GRANDE', 'Grande', '501 – 2 000 membres', Icons.corporate_fare_outlined, 'Grandes organisations régionales'), - _Plage('TRES_GRANDE', 'Très grande', '2 000+ membres', Icons.account_balance_outlined, 'Fédérations et réseaux nationaux'), + _Plage('PETITE', 'Petite', '1 – 100', Icons.group_outlined, 'Associations naissantes'), + _Plage('MOYENNE', 'Moyenne', '101 – 500', Icons.groups_outlined, 'Organisations établies'), + _Plage('GRANDE', 'Grande', '501 – 2 000', Icons.corporate_fare_outlined, 'Grandes structures'), + _Plage('TRES_GRANDE', 'Très grande', '2 000+', Icons.account_balance_outlined, 'Fédérations & réseaux'), ]; static const _formuleColors = { - 'BASIC': UnionFlowColors.unionGreen, + 'BASIC': UnionFlowColors.unionGreen, 'STANDARD': UnionFlowColors.gold, - 'PREMIUM': UnionFlowColors.indigo, + 'PREMIUM': UnionFlowColors.indigo, }; static const _formuleIcons = { - 'BASIC': Icons.star_border_rounded, + 'BASIC': Icons.star_border_rounded, 'STANDARD': Icons.star_half_rounded, - 'PREMIUM': Icons.star_rounded, + 'PREMIUM': Icons.star_rounded, }; static const _formuleFeatures = { - 'BASIC': ['Gestion des membres', 'Cotisations de base', 'Rapports mensuels', 'Support email'], + 'BASIC': ['Gestion des membres', 'Cotisations de base', 'Rapports mensuels', 'Support email'], 'STANDARD': ['Tout Basic +', 'Événements & solidarité', 'Communication interne', 'Tableaux de bord avancés', 'Support prioritaire'], - 'PREMIUM': ['Tout Standard +', 'Multi-organisations', 'Analytics temps réel', 'API ouverte', 'Support dédié 24/7'], + 'PREMIUM': ['Tout Standard +', 'Multi-organisations', 'Analytics temps réel', 'API ouverte', 'Support dédié 24/7'], }; List get _filteredFormules => widget.formules @@ -50,10 +52,35 @@ class _PlanSelectionPageState extends State { bool get _canProceed => _selectedPlage != null && _selectedFormule != null; + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onPlageSelected(String code) { + setState(() { + _selectedPlage = code; + _selectedFormule = null; + }); + // Scroll vers la section formules après la frame courante + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + ); + } + }); + } + @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ OnboardingStepHeader( @@ -64,41 +91,85 @@ class _PlanSelectionPageState extends State { ), Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 100), + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Step 1a: Taille de l'organisation + // ── Section 1 : Taille ──────────────────────────────── OnboardingSectionTitle( icon: Icons.people_alt_outlined, title: 'Taille de votre organisation', + badge: _selectedPlage != null ? 'Sélectionné ✓' : null, ), const SizedBox(height: 12), - ...(_plages.map((p) => _PlageCard( - plage: p, - selected: _selectedPlage == p.code, - onTap: () => setState(() { - _selectedPlage = p.code; - _selectedFormule = null; - }), - ))), - if (_selectedPlage != null) ...[ - const SizedBox(height: 28), - OnboardingSectionTitle( - icon: Icons.workspace_premium_outlined, - title: 'Niveau d\'abonnement', + // Grille 2×2 pour scanner rapidement + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 1.45, + children: _plages.map((p) => _PlageCard( + plage: p, + selected: _selectedPlage == p.code, + isDark: isDark, + onTap: () => _onPlageSelected(p.code), + )).toList(), + ), + + // ── Section 2 : Formule (apparaît en fondu) ────────── + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)), + child: child, + ), ), - const SizedBox(height: 12), - ..._filteredFormules.map((f) => _FormuleCard( - formule: f, - color: _formuleColors[f.code] ?? UnionFlowColors.unionGreen, - icon: _formuleIcons[f.code] ?? Icons.star_border_rounded, - features: _formuleFeatures[f.code] ?? [], - selected: _selectedFormule == f.code, - onTap: () => setState(() => _selectedFormule = f.code), - )), - ], + child: _selectedPlage == null + ? const SizedBox.shrink() + : Column( + key: ValueKey(_selectedPlage), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 28), + OnboardingSectionTitle( + icon: Icons.workspace_premium_outlined, + title: 'Niveau d\'abonnement', + badge: _selectedFormule != null ? 'Sélectionné ✓' : null, + ), + const SizedBox(height: 4), + Text( + 'Modifiable à tout moment depuis vos paramètres.', + style: TextStyle( + fontSize: 11, + color: isDark + ? AppColors.textSecondaryDark + : AppColors.textSecondary, + ), + ), + const SizedBox(height: 14), + ..._filteredFormules.map((f) => _FormuleCard( + formule: f, + color: _formuleColors[f.code] ?? UnionFlowColors.unionGreen, + icon: _formuleIcons[f.code] ?? Icons.star_border_rounded, + features: _formuleFeatures[f.code] ?? [], + selected: _selectedFormule == f.code, + isPopular: f.code == 'STANDARD', + isDark: isDark, + onTap: () => setState(() => _selectedFormule = f.code), + )), + ], + ), + ), ], ), ), @@ -107,7 +178,8 @@ class _PlanSelectionPageState extends State { ), bottomNavigationBar: OnboardingBottomBar( enabled: _canProceed, - label: 'Choisir la période', + label: 'Choisir la période →', + hint: _canProceed ? null : 'Sélectionnez une taille et une formule pour continuer', onPressed: () => context.read().add( OnboardingFormuleSelected( codeFormule: _selectedFormule!, @@ -119,7 +191,7 @@ class _PlanSelectionPageState extends State { } } -// ─── Widgets locaux ────────────────────────────────────────────────────────── +// ─── Carte de taille (grille 2×2) ──────────────────────────────────────────── class _Plage { final String code, label, sublabel, description; @@ -130,115 +202,120 @@ class _Plage { class _PlageCard extends StatelessWidget { final _Plage plage; final bool selected; + final bool isDark; final VoidCallback onTap; - const _PlageCard({required this.plage, required this.selected, required this.onTap}); + + const _PlageCard({ + required this.plage, + required this.selected, + required this.isDark, + required this.onTap, + }); @override Widget build(BuildContext context) { + final bgColor = 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; + return GestureDetector( onTap: onTap, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 10), + duration: const Duration(milliseconds: 180), decoration: BoxDecoration( - color: selected ? UnionFlowColors.unionGreenPale : UnionFlowColors.surface, + color: selected + ? UnionFlowColors.unionGreen.withOpacity(isDark ? 0.15 : 0.06) + : bgColor, border: Border.all( - color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border, + color: selected ? UnionFlowColors.unionGreen : borderColor, width: selected ? 2 : 1, ), borderRadius: BorderRadius.circular(14), - boxShadow: selected ? UnionFlowColors.greenGlowShadow : UnionFlowColors.softShadow, + boxShadow: selected ? UnionFlowColors.greenGlowShadow : null, ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: selected - ? UnionFlowColors.unionGreen - : UnionFlowColors.unionGreenPale, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(plage.icon, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: selected + ? UnionFlowColors.unionGreen + : UnionFlowColors.unionGreen.withOpacity(0.1), + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + plage.icon, color: selected ? Colors.white : UnionFlowColors.unionGreen, - size: 22), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - plage.label, - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 15, - color: selected - ? UnionFlowColors.unionGreen - : UnionFlowColors.textPrimary, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: selected - ? UnionFlowColors.unionGreen.withOpacity(0.15) - : UnionFlowColors.surfaceVariant, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - plage.sublabel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: selected - ? UnionFlowColors.unionGreen - : UnionFlowColors.textSecondary, - ), - ), - ), - ], - ), - const SizedBox(height: 2), - Text( - plage.description, - style: const TextStyle( - fontSize: 12, - color: UnionFlowColors.textSecondary), - ), - ], + size: 19, + ), ), - ), - Icon( - selected - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked, - color: selected - ? UnionFlowColors.unionGreen - : UnionFlowColors.border, - size: 22, - ), - ], - ), + Icon( + selected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked, + color: selected ? UnionFlowColors.unionGreen : borderColor, + size: 18, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + plage.label, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: selected ? UnionFlowColors.unionGreen : textPrimary, + ), + ), + const SizedBox(height: 1), + Text( + '${plage.sublabel} membres', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: selected + ? UnionFlowColors.unionGreen.withOpacity(0.8) + : textSecondary, + ), + ), + Text( + plage.description, + style: TextStyle( + fontSize: 9, + color: textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], ), ), ); } } +// ─── Carte de formule ───────────────────────────────────────────────────────── + class _FormuleCard extends StatelessWidget { final FormuleModel formule; final Color color; final IconData icon; final List features; final bool selected; + final bool isPopular; + final bool isDark; final VoidCallback onTap; const _FormuleCard({ @@ -247,72 +324,102 @@ class _FormuleCard extends StatelessWidget { required this.icon, required this.features, required this.selected, + required this.isPopular, + required this.isDark, required this.onTap, }); @override Widget build(BuildContext context) { + final bgCard = 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; + return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 12), + margin: const EdgeInsets.only(bottom: 14), decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: bgCard, border: Border.all( - color: selected ? color : UnionFlowColors.border, + color: selected ? color : borderColor, width: selected ? 2.5 : 1, ), borderRadius: BorderRadius.circular(16), boxShadow: selected - ? [ - BoxShadow( - color: color.withOpacity(0.2), - blurRadius: 20, - offset: const Offset(0, 8), - ) - ] - : UnionFlowColors.softShadow, + ? [BoxShadow(color: color.withOpacity(isDark ? 0.25 : 0.18), blurRadius: 20, offset: const Offset(0, 8))] + : isDark ? null : UnionFlowColors.softShadow, ), child: Column( children: [ - // Header + // ── Header coloré ───────────────────────────────── Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( - color: selected ? color : color.withOpacity(0.06), + color: selected ? color : color.withOpacity(isDark ? 0.15 : 0.06), borderRadius: const BorderRadius.vertical(top: Radius.circular(14)), ), child: Row( children: [ Icon(icon, - color: selected ? Colors.white : color, size: 24), + color: selected ? Colors.white : color, + size: 24), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - formule.libelle, - style: TextStyle( - color: selected ? Colors.white : color, - fontWeight: FontWeight.w800, - fontSize: 16, - ), + // Wrap permet au badge de passer à la ligne sur écrans étroits + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + formule.libelle, + style: TextStyle( + color: selected ? Colors.white : color, + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + if (isPopular) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: selected + ? Colors.white.withOpacity(0.25) + : color.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '⭐ POPULAIRE', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w800, + color: selected ? Colors.white : color, + letterSpacing: 0.3, + ), + ), + ), + ], ), if (formule.description != null) Text( formule.description!, style: TextStyle( - color: selected - ? Colors.white70 - : UnionFlowColors.textSecondary, + color: selected ? Colors.white70 : textSecondary, fontSize: 12, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ], ), ), + // Prix Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -325,41 +432,86 @@ class _FormuleCard extends StatelessWidget { ), ), Text( - 'FCFA / mois', + 'FCFA/mois', style: TextStyle( - color: selected - ? Colors.white70 - : UnionFlowColors.textSecondary, - fontSize: 11, + color: selected ? Colors.white70 : textSecondary, + fontSize: 10, ), ), + if (formule.prixAnnuel != null && formule.prixAnnuel! > 0) ...[ + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: selected + ? Colors.white.withOpacity(0.2) + : AppColors.success.withOpacity(0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '−${_annualSavingPct(formule)}% /an', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: selected ? Colors.white : AppColors.success, + ), + ), + ), + ], ], ), ], ), ), - // Features + + // ── Features ────────────────────────────────────── Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), child: Column( - children: features - .map((f) => Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - children: [ - Icon(Icons.check_circle_outline_rounded, - size: 16, color: color), - const SizedBox(width: 8), - Text(f, - style: const TextStyle( - fontSize: 13, - color: UnionFlowColors.textPrimary)), - ], - ), - )) - .toList(), + children: features.map((f) => Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Row( + children: [ + Icon(Icons.check_circle_outline_rounded, + size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + f, + style: TextStyle(fontSize: 13, color: textPrimary), + ), + ), + ], + ), + )).toList(), ), ), + + // ── Sélection indicator ──────────────────────────── + if (selected) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(isDark ? 0.2 : 0.08), + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle_rounded, size: 14, color: color), + const SizedBox(width: 6), + Text( + 'Formule sélectionnée', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + ), ], ), ), @@ -367,14 +519,15 @@ class _FormuleCard extends StatelessWidget { } String _formatPrix(double prix) { - if (prix >= 1000000) { - return '${(prix / 1000000).toStringAsFixed(1)} M'; - } - if (prix >= 1000) { - final k = (prix / 1000).toStringAsFixed(0); - return '$k 000'; - } + if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; + if (prix >= 1000) return '${(prix / 1000).toStringAsFixed(0)} k'; return prix.toStringAsFixed(0); } -} + String _annualSavingPct(FormuleModel f) { + if (f.prixAnnuel == null || f.prixAnnuel! <= 0 || f.prixMensuel <= 0) return '0'; + final monthly12 = f.prixMensuel * 12; + final saving = ((monthly12 - f.prixAnnuel!) / monthly12 * 100).round(); + return saving.toString(); + } +} diff --git a/lib/features/onboarding/presentation/pages/subscription_summary_page.dart b/lib/features/onboarding/presentation/pages/subscription_summary_page.dart index a8eb7ff..c3d7e51 100644 --- a/lib/features/onboarding/presentation/pages/subscription_summary_page.dart +++ b/lib/features/onboarding/presentation/pages/subscription_summary_page.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/onboarding_bloc.dart'; import '../../data/models/souscription_status_model.dart'; +import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart'; +import 'onboarding_shared_widgets.dart'; /// Étape 3 — Récapitulatif détaillé avant paiement class SubscriptionSummaryPage extends StatelessWidget { @@ -11,54 +13,46 @@ class SubscriptionSummaryPage extends StatelessWidget { const SubscriptionSummaryPage({super.key, required this.souscription}); static const _periodeLabels = { - 'MENSUEL': 'Mensuel', - 'TRIMESTRIEL': 'Trimestriel', - 'SEMESTRIEL': 'Semestriel', - 'ANNUEL': 'Annuel', + 'MENSUEL': 'Mensuel', + 'TRIMESTRIEL': 'Trimestriel', + 'SEMESTRIEL': 'Semestriel', + 'ANNUEL': 'Annuel', }; static const _periodeRemises = { - 'MENSUEL': null, - 'TRIMESTRIEL': '–5% de remise', - 'SEMESTRIEL': '–10% de remise', - 'ANNUEL': '–20% de remise', - }; - - static const _orgLabels = { - 'ASSOCIATION': 'Association / ONG locale', - 'MUTUELLE': 'Mutuelle (santé, fonctionnaires…)', - 'COOPERATIVE': 'Coopérative / Microfinance', - 'FEDERATION': 'Fédération / Grande ONG', + 'MENSUEL': null, + 'TRIMESTRIEL': '–5 % de remise', + 'SEMESTRIEL': '–10 % de remise', + 'ANNUEL': '–20 % de remise', }; static const _plageLabels = { - 'PETITE': '1–100 membres', - 'MOYENNE': '101–500 membres', - 'GRANDE': '501–2 000 membres', + 'PETITE': '1 – 100 membres', + 'MOYENNE': '101 – 500 membres', + 'GRANDE': '501 – 2 000 membres', 'TRES_GRANDE': '2 000+ membres', }; @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; final montant = souscription.montantTotal ?? 0; - final remise = _periodeRemises[souscription.typePeriode]; + final remise = _periodeRemises[souscription.typePeriode]; return Scaffold( - backgroundColor: UnionFlowColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ - // Header hero + // ── Header hero gradient ─────────────────────────── Container( - decoration: const BoxDecoration( - gradient: UnionFlowColors.primaryGradient, - ), + decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient), child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), child: Column( children: [ - // Step bar + // Barre de progression — toutes complètes à l'étape 3 Row( children: List.generate(3, (i) => Expanded( child: Container( @@ -84,52 +78,50 @@ class SubscriptionSummaryPage extends StatelessWidget { ), ), const SizedBox(height: 20), - // Montant principal + + // Icône principale Container( - width: 90, - height: 90, + width: 80, + height: 80, decoration: BoxDecoration( color: Colors.white.withOpacity(0.15), shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withOpacity(0.4), width: 2), + border: Border.all(color: Colors.white.withOpacity(0.4), width: 2), ), - child: const Icon(Icons.receipt_long_rounded, - color: Colors.white, size: 44), + child: const Icon(Icons.receipt_long_rounded, color: Colors.white, size: 40), ), const SizedBox(height: 14), + + // Montant Text( _formatPrix(montant), style: const TextStyle( color: Colors.white, - fontSize: 40, + fontSize: 42, fontWeight: FontWeight.w900, letterSpacing: -1, ), ), const Text( 'FCFA à régler', - style: TextStyle( - color: Colors.white70, - fontSize: 14, - fontWeight: FontWeight.w500), + style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500), ), + + // Badge remise if (remise != null) ...[ - const SizedBox(height: 8), + const SizedBox(height: 10), Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), decoration: BoxDecoration( color: UnionFlowColors.gold.withOpacity(0.3), borderRadius: BorderRadius.circular(20), - border: Border.all( - color: UnionFlowColors.goldLight.withOpacity(0.5)), + border: Border.all(color: UnionFlowColors.goldLight.withOpacity(0.5)), ), child: Text( - remise, + '🎉 $remise appliquée', style: const TextStyle( color: UnionFlowColors.goldLight, - fontSize: 12, + fontSize: 13, fontWeight: FontWeight.w700, ), ), @@ -141,28 +133,26 @@ class SubscriptionSummaryPage extends StatelessWidget { ), ), - // Content + // ── Contenu scrollable ────────────────────────────── Expanded( child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Organisation if (souscription.organisationNom != null) ...[ _DetailCard( title: 'Organisation', icon: Icons.business_rounded, iconColor: UnionFlowColors.indigo, + isDark: isDark, items: [ - _DetailItem( - label: 'Nom', - value: souscription.organisationNom!, - bold: true), - _DetailItem( - label: 'Type', - value: _orgLabels[souscription.typeOrganisation] ?? - souscription.typeOrganisation), + _DetailItem(label: 'Nom', value: souscription.organisationNom!, bold: true), + if (souscription.typeOrganisation != null) + _DetailItem(label: 'Type', value: souscription.typeOrganisation!), ], ), const SizedBox(height: 14), @@ -173,20 +163,18 @@ class SubscriptionSummaryPage extends StatelessWidget { title: 'Formule souscrite', icon: Icons.workspace_premium_rounded, iconColor: UnionFlowColors.gold, + isDark: isDark, items: [ + _DetailItem(label: 'Niveau', value: souscription.typeFormule, bold: true), _DetailItem( - label: 'Niveau', - value: souscription.typeFormule, - bold: true), - _DetailItem( - label: 'Taille', - value: _plageLabels[souscription.plageMembres] ?? - souscription.plageLibelle), + label: 'Capacité', + value: _plageLabels[souscription.plageMembres] ?? souscription.plageLibelle, + ), if (souscription.montantMensuelBase != null) _DetailItem( - label: 'Prix de base', - value: - '${_formatPrix(souscription.montantMensuelBase!)} FCFA/mois'), + label: 'Prix de base', + value: '${_formatPrix(souscription.montantMensuelBase!)} FCFA/mois', + ), ], ), const SizedBox(height: 14), @@ -196,71 +184,86 @@ class SubscriptionSummaryPage extends StatelessWidget { title: 'Facturation', icon: Icons.calendar_today_rounded, iconColor: UnionFlowColors.unionGreen, + isDark: isDark, items: [ _DetailItem( - label: 'Période', - value: - _periodeLabels[souscription.typePeriode] ?? - souscription.typePeriode), + label: 'Période', + value: _periodeLabels[souscription.typePeriode] ?? souscription.typePeriode, + ), if (souscription.coefficientApplique != null) _DetailItem( - label: 'Coefficient', - value: - '×${souscription.coefficientApplique!.toStringAsFixed(4)}'), - if (souscription.dateDebut != null && - souscription.dateFin != null) ...[ - _DetailItem( - label: 'Début', - value: _formatDate(souscription.dateDebut!)), - _DetailItem( - label: 'Fin', - value: _formatDate(souscription.dateFin!)), - ], + label: 'Coefficient', + value: '×${souscription.coefficientApplique!.toStringAsFixed(4)}', + ), + if (souscription.dateDebut != null) + _DetailItem(label: 'Début', value: _formatDate(souscription.dateDebut!)), + if (souscription.dateFin != null) + _DetailItem(label: 'Fin', value: _formatDate(souscription.dateFin!)), ], ), - const SizedBox(height: 14), + const SizedBox(height: 20), - // Montant total + // Bloc montant total — proéminent Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: UnionFlowColors.goldPale, + gradient: LinearGradient( + colors: isDark + ? [UnionFlowColors.gold.withOpacity(0.18), UnionFlowColors.amber.withOpacity(0.12)] + : [UnionFlowColors.goldPale, const Color(0xFFFFF3C8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), borderRadius: BorderRadius.circular(16), - border: Border.all( - color: UnionFlowColors.gold.withOpacity(0.4)), - boxShadow: UnionFlowColors.goldGlowShadow, + border: Border.all(color: UnionFlowColors.gold.withOpacity(0.4)), + boxShadow: isDark ? null : UnionFlowColors.goldGlowShadow, ), child: Row( children: [ Container( - width: 48, - height: 48, + width: 52, + height: 52, decoration: BoxDecoration( gradient: UnionFlowColors.goldGradient, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), child: const Icon(Icons.monetization_on_rounded, - color: Colors.white, size: 26), + color: Colors.white, size: 28), ), - const SizedBox(width: 14), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Total à payer', + Text( + 'TOTAL À PAYER', style: TextStyle( - color: UnionFlowColors.textSecondary, - fontSize: 13), + color: isDark + ? AppColors.textSecondaryDark + : AppColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ), Text( '${_formatPrix(montant)} FCFA', - style: const TextStyle( - color: UnionFlowColors.textPrimary, - fontSize: 22, + style: TextStyle( + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + fontSize: 24, fontWeight: FontWeight.w900, + letterSpacing: -0.5, ), ), + if (remise != null) + Text( + remise, + style: const TextStyle( + color: UnionFlowColors.gold, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), ], ), ), @@ -269,35 +272,32 @@ class SubscriptionSummaryPage extends StatelessWidget { ), const SizedBox(height: 20), - // Notes importantes + // Notes _NoteBox( icon: Icons.security_rounded, iconColor: UnionFlowColors.unionGreen, - backgroundColor: UnionFlowColors.unionGreenPale, - borderColor: UnionFlowColors.unionGreen.withOpacity(0.25), + accentColor: UnionFlowColors.unionGreen, + isDark: isDark, title: 'Paiement sécurisé', - message: - 'Votre paiement est traité de manière sécurisée via Wave Mobile Money. Une fois le paiement effectué, votre compte sera activé automatiquement.', + message: 'Votre paiement est traité de manière sécurisée via Wave Mobile Money. Une fois confirmé, votre compte sera activé automatiquement.', ), const SizedBox(height: 10), _NoteBox( icon: Icons.bolt_rounded, iconColor: UnionFlowColors.amber, - backgroundColor: const Color(0xFFFFFBF0), - borderColor: UnionFlowColors.amber.withOpacity(0.3), + accentColor: UnionFlowColors.amber, + isDark: isDark, title: 'Activation immédiate', - message: - 'Dès que le paiement est confirmé par Wave, votre compte d\'administrateur est activé et vous pouvez accéder à toutes les fonctionnalités de votre formule.', + message: 'Dès que Wave confirme le paiement, votre espace administrateur est activé avec toutes les fonctionnalités de votre formule.', ), const SizedBox(height: 10), _NoteBox( icon: Icons.support_agent_rounded, iconColor: UnionFlowColors.info, - backgroundColor: UnionFlowColors.infoPale, - borderColor: UnionFlowColors.info.withOpacity(0.2), + accentColor: UnionFlowColors.info, + isDark: isDark, title: 'Besoin d\'aide ?', - message: - 'En cas de problème lors du paiement, contactez notre support à support@unionflow.app — nous vous répondrons sous 24h.', + message: 'En cas de problème, contactez support@unionflow.app — réponse sous 24h.', ), ], ), @@ -305,41 +305,10 @@ class SubscriptionSummaryPage extends StatelessWidget { ), ], ), - bottomNavigationBar: Container( - padding: EdgeInsets.fromLTRB( - 20, 12, 20, MediaQuery.of(context).padding.bottom + 12), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => context - .read() - .add(const OnboardingChoixPaiementOuvert()), - icon: const Icon(Icons.payment_rounded), - label: const Text( - 'Choisir le moyen de paiement', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), - ), - style: ElevatedButton.styleFrom( - backgroundColor: UnionFlowColors.unionGreen, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), - shadowColor: UnionFlowColors.unionGreen.withOpacity(0.4), - elevation: 3, - ), - ), - ), + bottomNavigationBar: OnboardingBottomBar( + enabled: true, + label: 'Choisir le moyen de paiement', + onPressed: () => context.read().add(const OnboardingChoixPaiementOuvert()), ), ); } @@ -347,18 +316,13 @@ class SubscriptionSummaryPage extends StatelessWidget { String _formatPrix(double prix) { if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; final s = prix.toStringAsFixed(0); - if (s.length > 6) { - return '${s.substring(0, s.length - 6)} ${s.substring(s.length - 6, s.length - 3)} ${s.substring(s.length - 3)}'; - } - if (s.length > 3) { - return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; - } + if (s.length > 6) return '${s.substring(0, s.length - 6)} ${s.substring(s.length - 6, s.length - 3)} ${s.substring(s.length - 3)}'; + if (s.length > 3) return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}'; return s; } - String _formatDate(DateTime date) { - return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; - } + String _formatDate(DateTime date) => + '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; } // ─── Widgets locaux ────────────────────────────────────────────────────────── @@ -367,8 +331,7 @@ class _DetailItem { final String label; final String value; final bool bold; - const _DetailItem( - {required this.label, required this.value, this.bold = false}); + const _DetailItem({required this.label, required this.value, this.bold = false}); } class _DetailCard extends StatelessWidget { @@ -376,25 +339,34 @@ class _DetailCard extends StatelessWidget { final IconData icon; final Color iconColor; final List<_DetailItem> items; + final bool isDark; const _DetailCard({ required this.title, required this.icon, required this.iconColor, required this.items, + required this.isDark, }); @override Widget build(BuildContext context) { + final bgColor = 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; + return Container( decoration: BoxDecoration( - color: UnionFlowColors.surface, + color: bgColor, borderRadius: BorderRadius.circular(16), - boxShadow: UnionFlowColors.softShadow, + border: Border.all(color: borderColor), + boxShadow: isDark ? null : UnionFlowColors.softShadow, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // En-tête section Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), child: Row( @@ -403,7 +375,7 @@ class _DetailCard extends StatelessWidget { width: 34, height: 34, decoration: BoxDecoration( - color: iconColor.withOpacity(0.1), + color: iconColor.withOpacity(isDark ? 0.2 : 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: iconColor, size: 18), @@ -411,16 +383,16 @@ class _DetailCard extends StatelessWidget { const SizedBox(width: 10), Text( title, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w700, fontSize: 14, - color: UnionFlowColors.textPrimary, + color: textPrimary, ), ), ], ), ), - const Divider(height: 1, color: UnionFlowColors.border), + Divider(height: 1, color: borderColor), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), child: Column( @@ -430,23 +402,17 @@ class _DetailCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 120, - child: Text( - item.label, - style: const TextStyle( - color: UnionFlowColors.textSecondary, - fontSize: 13), - ), + width: 110, + child: Text(item.label, + style: TextStyle(color: textSecondary, fontSize: 13)), ), Expanded( child: Text( item.value, style: TextStyle( - color: UnionFlowColors.textPrimary, + color: textPrimary, fontSize: 13, - fontWeight: item.bold - ? FontWeight.w700 - : FontWeight.w500, + fontWeight: item.bold ? FontWeight.w700 : FontWeight.w500, ), ), ), @@ -464,26 +430,30 @@ class _DetailCard extends StatelessWidget { class _NoteBox extends StatelessWidget { final IconData icon; final Color iconColor; - final Color backgroundColor; - final Color borderColor; + final Color accentColor; + final bool isDark; final String title; final String message; const _NoteBox({ required this.icon, required this.iconColor, - required this.backgroundColor, - required this.borderColor, + required this.accentColor, + required this.isDark, required this.title, required this.message, }); @override Widget build(BuildContext context) { + final bgColor = accentColor.withOpacity(isDark ? 0.12 : 0.06); + final borderColor = accentColor.withOpacity(isDark ? 0.3 : 0.2); + final textSecondary= isDark ? AppColors.textSecondaryDark : AppColors.textSecondary; + return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: backgroundColor, + color: bgColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor), ), @@ -498,19 +468,12 @@ class _NoteBox extends StatelessWidget { children: [ Text( title, - style: TextStyle( - color: iconColor, - fontWeight: FontWeight.w700, - fontSize: 13, - ), + style: TextStyle(color: iconColor, fontWeight: FontWeight.w700, fontSize: 13), ), const SizedBox(height: 3), Text( message, - style: const TextStyle( - color: UnionFlowColors.textSecondary, - fontSize: 12, - height: 1.5), + style: TextStyle(color: textSecondary, fontSize: 12, height: 1.5), ), ], ), diff --git a/lib/features/onboarding/presentation/pages/wave_payment_page.dart b/lib/features/onboarding/presentation/pages/wave_payment_page.dart index 9c05b06..a7c6de7 100644 --- a/lib/features/onboarding/presentation/pages/wave_payment_page.dart +++ b/lib/features/onboarding/presentation/pages/wave_payment_page.dart @@ -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 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 } 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 @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( + 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() - .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() + .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), + ), + ], + ], ); }