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

@@ -141,6 +141,20 @@ class OnboardingStepPaiement extends OnboardingState {
/// Paiement confirmé — déclenche un re-check du statut du compte /// Paiement confirmé — déclenche un re-check du statut du compte
class OnboardingPaiementConfirme extends OnboardingState {} 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<Object?> get props => [message, souscription, waveLaunchUrl];
}
/// Étape 5 : en attente de validation SuperAdmin /// Étape 5 : en attente de validation SuperAdmin
class OnboardingStepAttente extends OnboardingState { class OnboardingStepAttente extends OnboardingState {
final SouscriptionStatusModel? souscription; final SouscriptionStatusModel? souscription;
@@ -316,16 +330,32 @@ class OnboardingBloc extends Bloc<OnboardingEvent, OnboardingState> {
Future<void> _onRetourDepuisWave( Future<void> _onRetourDepuisWave(
OnboardingRetourDepuisWave event, Emitter<OnboardingState> emit) async { OnboardingRetourDepuisWave event, Emitter<OnboardingState> emit) async {
emit(OnboardingLoading()); emit(OnboardingLoading());
final souscId = _souscription?.souscriptionId;
final waveUrl = _souscription?.waveLaunchUrl;
if (souscId == null) {
emit(const OnboardingError('Souscription introuvable.'));
return;
}
try { try {
final souscId = _souscription?.souscriptionId; final success = await _datasource.confirmerPaiement(souscId);
if (souscId != null) { if (success) {
await _datasource.confirmerPaiement(souscId); 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) { } catch (e) {
emit(OnboardingPaiementConfirme()); emit(OnboardingPaiementEchoue(
message: 'Erreur lors de la confirmation: ${e.toString().replaceFirst("Exception: ", "")}',
souscription: _souscription!,
waveLaunchUrl: waveUrl ?? '',
));
} }
} }
} }

View File

@@ -53,6 +53,43 @@ class SouscriptionDatasource {
return null; 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 /// Crée une demande de souscription
Future<SouscriptionStatusModel?> creerDemande({ Future<SouscriptionStatusModel?> creerDemande({
required String typeFormule, required String typeFormule,
@@ -63,14 +100,15 @@ class SouscriptionDatasource {
}) async { }) async {
try { try {
final opts = await _authOptions(); final opts = await _authOptions();
final mappedType = _mapTypeOrganisationBilling(typeOrganisation);
final body = <String, dynamic>{ final body = <String, dynamic>{
'typeFormule': typeFormule, 'typeFormule': typeFormule,
'plageMembres': plageMembres, 'plageMembres': plageMembres,
'typePeriode': typePeriode, 'typePeriode': typePeriode,
'organisationId': organisationId, 'organisationId': organisationId,
}; };
if (typeOrganisation != null && typeOrganisation.isNotEmpty) { if (mappedType != null && mappedType.isNotEmpty) {
body['typeOrganisation'] = typeOrganisation; body['typeOrganisation'] = mappedType;
} }
final response = await _dio.post( final response = await _dio.post(
'$_base/api/souscriptions/demande', '$_base/api/souscriptions/demande',

View File

@@ -4,6 +4,7 @@ import '../../bloc/onboarding_bloc.dart';
import '../../../../core/di/injection.dart'; import '../../../../core/di/injection.dart';
import '../../../../features/authentication/presentation/bloc/auth_bloc.dart'; import '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import 'plan_selection_page.dart'; import 'plan_selection_page.dart';
import 'period_selection_page.dart'; import 'period_selection_page.dart';
import 'subscription_summary_page.dart'; import 'subscription_summary_page.dart';
@@ -57,7 +58,7 @@ class _OnboardingFlowView extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
if (state is OnboardingLoading || state is OnboardingInitial || state is OnboardingPaiementConfirme) { if (state is OnboardingLoading || state is OnboardingInitial || state is OnboardingPaiementConfirme) {
return Scaffold( return Scaffold(
backgroundColor: UnionFlowColors.background, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Center( body: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -83,7 +84,7 @@ class _OnboardingFlowView extends StatelessWidget {
if (state is OnboardingError) { if (state is OnboardingError) {
return Scaffold( return Scaffold(
backgroundColor: UnionFlowColors.background, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -115,7 +116,7 @@ class _OnboardingFlowView extends StatelessWidget {
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen, backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: Colors.white, foregroundColor: AppColors.onPrimary,
), ),
child: const Text('Réessayer'), 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) { if (state is OnboardingStepAttente) {
return AwaitingValidationPage(souscription: state.souscription); return AwaitingValidationPage(souscription: state.souscription);
} }
@@ -176,7 +185,7 @@ class _RejectedPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: UnionFlowColors.background, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -247,7 +256,7 @@ class _RejectedPage extends StatelessWidget {
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen, backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: Colors.white, foregroundColor: AppColors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12)),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart';
/// Header commun à toutes les étapes d'onboarding /// Header commun à toutes les étapes d'onboarding
@@ -19,9 +20,7 @@ class OnboardingStepHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient),
gradient: UnionFlowColors.primaryGradient,
),
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
@@ -29,6 +28,7 @@ class OnboardingStepHeader extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Barre de progression segmentée
Row( Row(
children: List.generate(total, (i) { children: List.generate(total, (i) {
final done = i < step; final done = i < step;
@@ -37,9 +37,7 @@ class OnboardingStepHeader extends StatelessWidget {
height: 4, height: 4,
margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: done color: done ? Colors.white : Colors.white.withOpacity(0.3),
? Colors.white
: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(2), 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 { class OnboardingSectionTitle extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String? badge; // Ex: "Étape 1 complète ✓"
const OnboardingSectionTitle({ const OnboardingSectionTitle({
super.key, super.key,
required this.icon, required this.icon,
required this.title, required this.title,
this.badge,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final textColor = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary;
return Row( return Row(
children: [ children: [
Icon(icon, color: UnionFlowColors.unionGreen, size: 20), Icon(icon, color: UnionFlowColors.unionGreen, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
title, child: Text(
style: const TextStyle( title,
color: UnionFlowColors.textPrimary, style: TextStyle(
fontWeight: FontWeight.w700, color: textColor,
fontSize: 16, 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 { class OnboardingBottomBar extends StatelessWidget {
final bool enabled; final bool enabled;
final String label; final String label;
final VoidCallback onPressed; final VoidCallback onPressed;
final String? hint; // Texte optionnel au-dessus du bouton
const OnboardingBottomBar({ const OnboardingBottomBar({
super.key, super.key,
required this.enabled, required this.enabled,
required this.label, required this.label,
required this.onPressed, required this.onPressed,
this.hint,
}); });
@override @override
Widget build(BuildContext context) { 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( return Container(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
20, 12, 20, MediaQuery.of(context).padding.bottom + 12), 20, 12, 20, MediaQuery.of(context).padding.bottom + 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.surface, color: bgColor,
border: Border(top: BorderSide(color: borderColor, width: 0.5)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: AppColors.shadow, blurRadius: 12, offset: const Offset(0, -4)),
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, -4),
),
], ],
), ),
child: SizedBox( child: Column(
width: double.infinity, mainAxisSize: MainAxisSize.min,
child: ElevatedButton( children: [
onPressed: enabled ? onPressed : null, if (hint != null) ...[
style: ElevatedButton.styleFrom( Text(
backgroundColor: UnionFlowColors.unionGreen, hint!,
disabledBackgroundColor: UnionFlowColors.border, style: TextStyle(fontSize: 11, color: hintColor),
foregroundColor: Colors.white, textAlign: TextAlign.center,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
), ),
elevation: enabled ? 2 : 0, const SizedBox(height: 8),
shadowColor: UnionFlowColors.unionGreen.withOpacity(0.4), ],
), SizedBox(
child: Text( width: double.infinity,
label, child: ElevatedButton(
style: const TextStyle( onPressed: enabled ? onPressed : null,
fontSize: 16, style: ElevatedButton.styleFrom(
fontWeight: FontWeight.w700, backgroundColor: UnionFlowColors.unionGreen,
letterSpacing: 0.3, 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,
),
),
), ),
), ),
), ],
), ),
); );
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/onboarding_bloc.dart'; import '../../bloc/onboarding_bloc.dart';
import '../../data/models/souscription_status_model.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 '../../../../shared/design_system/tokens/unionflow_colors.dart';
/// Écran de sélection du moyen de paiement /// Écran de sélection du moyen de paiement
@@ -17,13 +18,17 @@ class PaymentMethodPage extends StatefulWidget {
class _PaymentMethodPageState extends State<PaymentMethodPage> { class _PaymentMethodPageState extends State<PaymentMethodPage> {
String? _selected; 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 = [ static const _methods = [
_PayMethod( _PayMethod(
id: 'WAVE', id: 'WAVE',
name: 'Wave Mobile Money', name: 'Wave Mobile Money',
description: 'Paiement rapide via votre compte Wave', description: 'Paiement rapide via votre compte Wave',
logoAsset: 'assets/images/payment_methods/wave/logo.png', logoAsset: 'assets/images/payment_methods/wave/logo.png',
color: Color(0xFF00B9F1), color: _waveBlue,
available: true, available: true,
badge: 'Recommandé', badge: 'Recommandé',
), ),
@@ -32,7 +37,7 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
name: 'Orange Money', name: 'Orange Money',
description: 'Paiement via Orange Money', description: 'Paiement via Orange Money',
logoAsset: 'assets/images/payment_methods/orange_money/logo-black.png', logoAsset: 'assets/images/payment_methods/orange_money/logo-black.png',
color: Color(0xFFFF6600), color: _orangeOrange,
available: false, available: false,
badge: 'Prochainement', badge: 'Prochainement',
), ),
@@ -40,17 +45,20 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
backgroundColor: UnionFlowColors.background, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column( body: Column(
children: [ children: [
// Header // Header gradient — toujours sombre, texte blanc intentionnel
Container( Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient),
gradient: UnionFlowColors.primaryGradient,
),
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
@@ -60,25 +68,19 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () => Navigator.of(context).maybePop(), onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.arrow_back_rounded, icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
color: Colors.white),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( const Text(
'Moyen de paiement', 'Moyen de paiement',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800),
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w800,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Choisissez comment régler votre souscription', 'Choisissez comment régler votre souscription',
style: TextStyle( style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 13),
color: Colors.white.withOpacity(0.8), fontSize: 13),
), ),
], ],
), ),
@@ -92,13 +94,14 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Montant rappel // Rappel montant
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.surface, color: bgCard,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: UnionFlowColors.softShadow, border: Border.all(color: borderColor),
boxShadow: isDark ? null : UnionFlowColors.softShadow,
), ),
child: Row( child: Row(
children: [ children: [
@@ -109,24 +112,19 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
gradient: UnionFlowColors.goldGradient, gradient: UnionFlowColors.goldGradient,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon(Icons.receipt_rounded, child: const Icon(Icons.receipt_rounded, color: Colors.white, size: 22),
color: Colors.white, size: 22),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text('Montant total',
'Montant total', style: TextStyle(color: textSecondary, fontSize: 12)),
style: TextStyle(
color: UnionFlowColors.textSecondary,
fontSize: 12),
),
Text( Text(
'${_formatPrix(montant)} FCFA', '${_formatPrix(montant)} FCFA',
style: const TextStyle( style: TextStyle(
color: UnionFlowColors.textPrimary, color: textPrimary,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
), ),
@@ -138,18 +136,12 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
const Text( Text('Organisation',
'Organisation', style: TextStyle(color: textSecondary, fontSize: 11)),
style: TextStyle(
color: UnionFlowColors.textSecondary,
fontSize: 11),
),
Text( Text(
widget.souscription.organisationNom!, widget.souscription.organisationNom!,
style: const TextStyle( style: TextStyle(
color: UnionFlowColors.textPrimary, color: textPrimary, fontSize: 12, fontWeight: FontWeight.w600),
fontSize: 12,
fontWeight: FontWeight.w600),
), ),
], ],
), ),
@@ -158,10 +150,10 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( Text(
'Sélectionnez un moyen de paiement', 'Sélectionnez un moyen de paiement',
style: TextStyle( style: TextStyle(
color: UnionFlowColors.textPrimary, color: textPrimary,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 15, fontSize: 15,
), ),
@@ -169,36 +161,33 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
const SizedBox(height: 12), const SizedBox(height: 12),
..._methods.map((m) => _MethodCard( ..._methods.map((m) => _MethodCard(
method: m, method: m,
selected: _selected == m.id, selected: _selected == m.id,
onTap: m.available onTap: m.available ? () => setState(() => _selected = m.id) : null,
? () => setState(() => _selected = m.id) )),
: null,
)),
const SizedBox(height: 20), const SizedBox(height: 20),
// Bandeau sécurité — fond vert pâle adaptatif
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.unionGreenPale, color: isDark
? UnionFlowColors.unionGreen.withOpacity(0.1)
: UnionFlowColors.unionGreenPale,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(color: UnionFlowColors.unionGreen.withOpacity(0.25)),
color:
UnionFlowColors.unionGreen.withOpacity(0.25)),
), ),
child: const Row( child: const Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.lock_rounded, Icon(Icons.lock_rounded, color: UnionFlowColors.unionGreen, size: 18),
color: UnionFlowColors.unionGreen, size: 18),
SizedBox(width: 10), SizedBox(width: 10),
Expanded( Expanded(
child: Text( 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.', '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( style: TextStyle(
fontSize: 12, fontSize: 12, color: UnionFlowColors.unionGreen, height: 1.4),
color: UnionFlowColors.unionGreen,
height: 1.4),
), ),
), ),
], ],
@@ -214,41 +203,34 @@ class _PaymentMethodPageState extends State<PaymentMethodPage> {
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
20, 12, 20, MediaQuery.of(context).padding.bottom + 12), 20, 12, 20, MediaQuery.of(context).padding.bottom + 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.surface, color: bgCard,
border: Border(top: BorderSide(color: borderColor)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: AppColors.shadow, blurRadius: 12, offset: const Offset(0, -4)),
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, -4),
),
], ],
), ),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _selected == 'WAVE' onPressed: _selected == 'WAVE'
? () => context ? () => context.read<OnboardingBloc>().add(const OnboardingPaiementInitie())
.read<OnboardingBloc>()
.add(const OnboardingPaiementInitie())
: null, : null,
icon: const Icon(Icons.open_in_new_rounded), icon: const Icon(Icons.open_in_new_rounded),
label: Text( label: Text(
_selected == 'WAVE' _selected == 'WAVE'
? 'Payer avec Wave' ? 'Payer avec Wave'
: 'Sélectionnez un moyen de paiement', : 'Sélectionnez un moyen de paiement',
style: style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00B9F1), backgroundColor: _waveBlue,
disabledBackgroundColor: UnionFlowColors.border, disabledBackgroundColor: isDark ? AppColors.borderDark : AppColors.border,
foregroundColor: Colors.white, foregroundColor: AppColors.onPrimary,
disabledForegroundColor: UnionFlowColors.textSecondary, disabledForegroundColor: isDark ? AppColors.textSecondaryDark : AppColors.textSecondary,
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
borderRadius: BorderRadius.circular(14)),
elevation: _selected == 'WAVE' ? 3 : 0, 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<PaymentMethodPage> {
String _formatPrix(double prix) { String _formatPrix(double prix) {
if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
final s = prix.toStringAsFixed(0); final s = prix.toStringAsFixed(0);
if (s.length > 3) { if (s.length > 3) return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}';
return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}';
}
return s; return s;
} }
} }
@@ -287,15 +267,18 @@ class _MethodCard extends StatelessWidget {
final bool selected; final bool selected;
final VoidCallback? onTap; final VoidCallback? onTap;
const _MethodCard({ const _MethodCard({required this.method, required this.selected, this.onTap});
required this.method,
required this.selected,
this.onTap,
});
@override @override
Widget build(BuildContext context) { 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( return GestureDetector(
onTap: onTap, onTap: onTap,
@@ -304,51 +287,43 @@ class _MethodCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: disabled color: disabled
? UnionFlowColors.surfaceVariant ? bgDisabled
: selected : selected
? method.color.withOpacity(0.06) ? method.color.withOpacity(isDark ? 0.12 : 0.06)
: UnionFlowColors.surface, : bgSurface,
border: Border.all( border: Border.all(
color: selected ? method.color : UnionFlowColors.border, color: selected ? method.color : borderDefault,
width: selected ? 2 : 1, width: selected ? 2 : 1,
), ),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: disabled boxShadow: disabled
? [] ? []
: selected : selected
? [ ? [BoxShadow(color: method.color.withOpacity(0.15), blurRadius: 16, offset: const Offset(0, 6))]
BoxShadow( : isDark ? null : UnionFlowColors.softShadow,
color: method.color.withOpacity(0.15),
blurRadius: 16,
offset: const Offset(0, 6),
)
]
: UnionFlowColors.softShadow,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row( child: Row(
children: [ children: [
// Logo image // Logo — fond blanc intentionnel (logos de marque)
Container( Container(
width: 56, width: 56,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: disabled color: disabled ? bgDisabled : Colors.white,
? UnionFlowColors.border
: Colors.white,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: UnionFlowColors.border), border: Border.all(color: borderDefault),
), ),
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: Image.asset( child: Image.asset(
method.logoAsset, method.logoAsset,
fit: BoxFit.contain, fit: BoxFit.contain,
color: disabled ? UnionFlowColors.textTertiary : null, color: disabled ? textTertiary : null,
colorBlendMode: disabled ? BlendMode.srcIn : null, colorBlendMode: disabled ? BlendMode.srcIn : null,
errorBuilder: (_, __, ___) => Icon( errorBuilder: (_, __, ___) => Icon(
Icons.account_balance_wallet_rounded, Icons.account_balance_wallet_rounded,
color: disabled ? UnionFlowColors.textTertiary : method.color, color: disabled ? textTertiary : method.color,
size: 24, size: 24,
), ),
), ),
@@ -363,18 +338,14 @@ class _MethodCard extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 14, fontSize: 14,
color: disabled color: disabled ? textTertiary : textPrimary,
? UnionFlowColors.textTertiary
: UnionFlowColors.textPrimary,
), ),
), ),
Text( Text(
method.description, method.description,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: disabled color: disabled ? textTertiary : textSecondary,
? UnionFlowColors.textTertiary
: UnionFlowColors.textSecondary,
), ),
), ),
], ],
@@ -384,14 +355,13 @@ class _MethodCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: disabled color: disabled
? UnionFlowColors.border ? borderDefault
: method.available : method.available
? method.color.withOpacity(0.1) ? method.color.withOpacity(isDark ? 0.2 : 0.1)
: UnionFlowColors.surfaceVariant, : isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
@@ -400,20 +370,18 @@ class _MethodCard extends StatelessWidget {
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: disabled color: disabled
? UnionFlowColors.textTertiary ? textTertiary
: method.available : method.available
? method.color ? method.color
: UnionFlowColors.textSecondary, : textSecondary,
), ),
), ),
), ),
if (!disabled) ...[ if (!disabled) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Icon( Icon(
selected selected ? Icons.check_circle_rounded : Icons.radio_button_unchecked,
? Icons.check_circle_rounded color: selected ? method.color : borderDefault,
: Icons.radio_button_unchecked,
color: selected ? method.color : UnionFlowColors.border,
size: 20, size: 20,
), ),
], ],

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/onboarding_bloc.dart'; import '../../bloc/onboarding_bloc.dart';
import '../../data/models/formule_model.dart'; import '../../data/models/formule_model.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import 'onboarding_shared_widgets.dart'; import 'onboarding_shared_widgets.dart';
@@ -17,30 +18,31 @@ class PlanSelectionPage extends StatefulWidget {
class _PlanSelectionPageState extends State<PlanSelectionPage> { class _PlanSelectionPageState extends State<PlanSelectionPage> {
String? _selectedPlage; String? _selectedPlage;
String? _selectedFormule; String? _selectedFormule;
final _scrollController = ScrollController();
static const _plages = [ static const _plages = [
_Plage('PETITE', 'Petite', '1 100 membres', Icons.group_outlined, 'Associations naissantes et petites structures'), _Plage('PETITE', 'Petite', '1 100', Icons.group_outlined, 'Associations naissantes'),
_Plage('MOYENNE', 'Moyenne', '101 500 membres', Icons.groups_outlined, 'Associations établies en croissance'), _Plage('MOYENNE', 'Moyenne', '101 500', Icons.groups_outlined, 'Organisations établies'),
_Plage('GRANDE', 'Grande', '501 2 000 membres', Icons.corporate_fare_outlined, 'Grandes organisations régionales'), _Plage('GRANDE', 'Grande', '501 2 000', Icons.corporate_fare_outlined, 'Grandes structures'),
_Plage('TRES_GRANDE', 'Très grande', '2 000+ membres', Icons.account_balance_outlined, 'Fédérations et réseaux nationaux'), _Plage('TRES_GRANDE', 'Très grande', '2 000+', Icons.account_balance_outlined, 'Fédérations & réseaux'),
]; ];
static const _formuleColors = { static const _formuleColors = {
'BASIC': UnionFlowColors.unionGreen, 'BASIC': UnionFlowColors.unionGreen,
'STANDARD': UnionFlowColors.gold, 'STANDARD': UnionFlowColors.gold,
'PREMIUM': UnionFlowColors.indigo, 'PREMIUM': UnionFlowColors.indigo,
}; };
static const _formuleIcons = { static const _formuleIcons = {
'BASIC': Icons.star_border_rounded, 'BASIC': Icons.star_border_rounded,
'STANDARD': Icons.star_half_rounded, 'STANDARD': Icons.star_half_rounded,
'PREMIUM': Icons.star_rounded, 'PREMIUM': Icons.star_rounded,
}; };
static const _formuleFeatures = { 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'], '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<FormuleModel> get _filteredFormules => widget.formules List<FormuleModel> get _filteredFormules => widget.formules
@@ -50,10 +52,35 @@ class _PlanSelectionPageState extends State<PlanSelectionPage> {
bool get _canProceed => _selectedPlage != null && _selectedFormule != null; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: UnionFlowColors.background, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column( body: Column(
children: [ children: [
OnboardingStepHeader( OnboardingStepHeader(
@@ -64,41 +91,85 @@ class _PlanSelectionPageState extends State<PlanSelectionPage> {
), ),
Expanded( Expanded(
child: SingleChildScrollView( 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Step 1a: Taille de l'organisation // ── Section 1 : Taille ────────────────────────────────
OnboardingSectionTitle( OnboardingSectionTitle(
icon: Icons.people_alt_outlined, icon: Icons.people_alt_outlined,
title: 'Taille de votre organisation', title: 'Taille de votre organisation',
badge: _selectedPlage != null ? 'Sélectionné ✓' : null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
...(_plages.map((p) => _PlageCard(
plage: p,
selected: _selectedPlage == p.code,
onTap: () => setState(() {
_selectedPlage = p.code;
_selectedFormule = null;
}),
))),
if (_selectedPlage != null) ...[ // Grille 2×2 pour scanner rapidement
const SizedBox(height: 28), GridView.count(
OnboardingSectionTitle( shrinkWrap: true,
icon: Icons.workspace_premium_outlined, physics: const NeverScrollableScrollPhysics(),
title: 'Niveau d\'abonnement', 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<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)),
child: child,
),
), ),
const SizedBox(height: 12), child: _selectedPlage == null
..._filteredFormules.map((f) => _FormuleCard( ? const SizedBox.shrink()
formule: f, : Column(
color: _formuleColors[f.code] ?? UnionFlowColors.unionGreen, key: ValueKey(_selectedPlage),
icon: _formuleIcons[f.code] ?? Icons.star_border_rounded, crossAxisAlignment: CrossAxisAlignment.start,
features: _formuleFeatures[f.code] ?? [], children: [
selected: _selectedFormule == f.code, const SizedBox(height: 28),
onTap: () => setState(() => _selectedFormule = f.code), 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<PlanSelectionPage> {
), ),
bottomNavigationBar: OnboardingBottomBar( bottomNavigationBar: OnboardingBottomBar(
enabled: _canProceed, 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<OnboardingBloc>().add( onPressed: () => context.read<OnboardingBloc>().add(
OnboardingFormuleSelected( OnboardingFormuleSelected(
codeFormule: _selectedFormule!, codeFormule: _selectedFormule!,
@@ -119,7 +191,7 @@ class _PlanSelectionPageState extends State<PlanSelectionPage> {
} }
} }
// ─── Widgets locaux ────────────────────────────────────────────────────────── // ─── Carte de taille (grille 2×2) ────────────────────────────────────────────
class _Plage { class _Plage {
final String code, label, sublabel, description; final String code, label, sublabel, description;
@@ -130,115 +202,120 @@ class _Plage {
class _PlageCard extends StatelessWidget { class _PlageCard extends StatelessWidget {
final _Plage plage; final _Plage plage;
final bool selected; final bool selected;
final bool isDark;
final VoidCallback onTap; 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 @override
Widget build(BuildContext context) { 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( return GestureDetector(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? UnionFlowColors.unionGreenPale : UnionFlowColors.surface, color: selected
? UnionFlowColors.unionGreen.withOpacity(isDark ? 0.15 : 0.06)
: bgColor,
border: Border.all( border: Border.all(
color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border, color: selected ? UnionFlowColors.unionGreen : borderColor,
width: selected ? 2 : 1, width: selected ? 2 : 1,
), ),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: selected ? UnionFlowColors.greenGlowShadow : UnionFlowColors.softShadow, boxShadow: selected ? UnionFlowColors.greenGlowShadow : null,
), ),
child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column(
child: Row( crossAxisAlignment: CrossAxisAlignment.start,
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
Container( children: [
width: 44, Row(
height: 44, mainAxisAlignment: MainAxisAlignment.spaceBetween,
decoration: BoxDecoration( children: [
color: selected Container(
? UnionFlowColors.unionGreen width: 36,
: UnionFlowColors.unionGreenPale, height: 36,
borderRadius: BorderRadius.circular(10), decoration: BoxDecoration(
), color: selected
child: Icon(plage.icon, ? UnionFlowColors.unionGreen
: UnionFlowColors.unionGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(9),
),
child: Icon(
plage.icon,
color: selected ? Colors.white : UnionFlowColors.unionGreen, color: selected ? Colors.white : UnionFlowColors.unionGreen,
size: 22), size: 19,
), ),
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),
),
],
), ),
), Icon(
Icon( selected
selected ? Icons.check_circle_rounded
? Icons.check_circle_rounded : Icons.radio_button_unchecked,
: Icons.radio_button_unchecked, color: selected ? UnionFlowColors.unionGreen : borderColor,
color: selected size: 18,
? UnionFlowColors.unionGreen ),
: UnionFlowColors.border, ],
size: 22, ),
), 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 { class _FormuleCard extends StatelessWidget {
final FormuleModel formule; final FormuleModel formule;
final Color color; final Color color;
final IconData icon; final IconData icon;
final List<String> features; final List<String> features;
final bool selected; final bool selected;
final bool isPopular;
final bool isDark;
final VoidCallback onTap; final VoidCallback onTap;
const _FormuleCard({ const _FormuleCard({
@@ -247,72 +324,102 @@ class _FormuleCard extends StatelessWidget {
required this.icon, required this.icon,
required this.features, required this.features,
required this.selected, required this.selected,
required this.isPopular,
required this.isDark,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { 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( return GestureDetector(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.surface, color: bgCard,
border: Border.all( border: Border.all(
color: selected ? color : UnionFlowColors.border, color: selected ? color : borderColor,
width: selected ? 2.5 : 1, width: selected ? 2.5 : 1,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: selected boxShadow: selected
? [ ? [BoxShadow(color: color.withOpacity(isDark ? 0.25 : 0.18), blurRadius: 20, offset: const Offset(0, 8))]
BoxShadow( : isDark ? null : UnionFlowColors.softShadow,
color: color.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 8),
)
]
: UnionFlowColors.softShadow,
), ),
child: Column( child: Column(
children: [ children: [
// Header // ── Header coloré ─────────────────────────────────
Container( Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration( 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)), borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
), ),
child: Row( child: Row(
children: [ children: [
Icon(icon, Icon(icon,
color: selected ? Colors.white : color, size: 24), color: selected ? Colors.white : color,
size: 24),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( // Wrap permet au badge de passer à la ligne sur écrans étroits
formule.libelle, Wrap(
style: TextStyle( spacing: 8,
color: selected ? Colors.white : color, runSpacing: 4,
fontWeight: FontWeight.w800, crossAxisAlignment: WrapCrossAlignment.center,
fontSize: 16, 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) if (formule.description != null)
Text( Text(
formule.description!, formule.description!,
style: TextStyle( style: TextStyle(
color: selected color: selected ? Colors.white70 : textSecondary,
? Colors.white70
: UnionFlowColors.textSecondary,
fontSize: 12, fontSize: 12,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
// Prix
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
@@ -325,41 +432,86 @@ class _FormuleCard extends StatelessWidget {
), ),
), ),
Text( Text(
'FCFA / mois', 'FCFA/mois',
style: TextStyle( style: TextStyle(
color: selected color: selected ? Colors.white70 : textSecondary,
? Colors.white70 fontSize: 10,
: UnionFlowColors.textSecondary,
fontSize: 11,
), ),
), ),
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(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
child: Column( child: Column(
children: features children: features.map((f) => Padding(
.map((f) => Padding( padding: const EdgeInsets.only(bottom: 7),
padding: const EdgeInsets.only(bottom: 6), child: Row(
child: Row( children: [
children: [ Icon(Icons.check_circle_outline_rounded,
Icon(Icons.check_circle_outline_rounded, size: 16, color: color),
size: 16, color: color), const SizedBox(width: 8),
const SizedBox(width: 8), Expanded(
Text(f, child: Text(
style: const TextStyle( f,
fontSize: 13, style: TextStyle(fontSize: 13, color: textPrimary),
color: UnionFlowColors.textPrimary)), ),
], ),
), ],
)) ),
.toList(), )).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) { String _formatPrix(double prix) {
if (prix >= 1000000) { if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
return '${(prix / 1000000).toStringAsFixed(1)} M'; if (prix >= 1000) return '${(prix / 1000).toStringAsFixed(0)} k';
}
if (prix >= 1000) {
final k = (prix / 1000).toStringAsFixed(0);
return '$k 000';
}
return prix.toStringAsFixed(0); 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();
}
}

View File

@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/onboarding_bloc.dart'; import '../../bloc/onboarding_bloc.dart';
import '../../data/models/souscription_status_model.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 '../../../../shared/design_system/tokens/unionflow_colors.dart';
import 'onboarding_shared_widgets.dart';
/// Étape 3 — Récapitulatif détaillé avant paiement /// Étape 3 — Récapitulatif détaillé avant paiement
class SubscriptionSummaryPage extends StatelessWidget { class SubscriptionSummaryPage extends StatelessWidget {
@@ -11,54 +13,46 @@ class SubscriptionSummaryPage extends StatelessWidget {
const SubscriptionSummaryPage({super.key, required this.souscription}); const SubscriptionSummaryPage({super.key, required this.souscription});
static const _periodeLabels = { static const _periodeLabels = {
'MENSUEL': 'Mensuel', 'MENSUEL': 'Mensuel',
'TRIMESTRIEL': 'Trimestriel', 'TRIMESTRIEL': 'Trimestriel',
'SEMESTRIEL': 'Semestriel', 'SEMESTRIEL': 'Semestriel',
'ANNUEL': 'Annuel', 'ANNUEL': 'Annuel',
}; };
static const _periodeRemises = { static const _periodeRemises = {
'MENSUEL': null, 'MENSUEL': null,
'TRIMESTRIEL': '5% de remise', 'TRIMESTRIEL': '5 % de remise',
'SEMESTRIEL': '10% de remise', 'SEMESTRIEL': '10 % de remise',
'ANNUEL': '20% 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',
}; };
static const _plageLabels = { static const _plageLabels = {
'PETITE': '1100 membres', 'PETITE': '1 100 membres',
'MOYENNE': '101500 membres', 'MOYENNE': '101 500 membres',
'GRANDE': '5012 000 membres', 'GRANDE': '501 2 000 membres',
'TRES_GRANDE': '2 000+ membres', 'TRES_GRANDE': '2 000+ membres',
}; };
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final montant = souscription.montantTotal ?? 0; final montant = souscription.montantTotal ?? 0;
final remise = _periodeRemises[souscription.typePeriode]; final remise = _periodeRemises[souscription.typePeriode];
return Scaffold( return Scaffold(
backgroundColor: UnionFlowColors.background, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column( body: Column(
children: [ children: [
// Header hero // ── Header hero gradient ───────────────────────────
Container( Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient),
gradient: UnionFlowColors.primaryGradient,
),
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
child: Column( child: Column(
children: [ children: [
// Step bar // Barre de progression — toutes complètes à l'étape 3
Row( Row(
children: List.generate(3, (i) => Expanded( children: List.generate(3, (i) => Expanded(
child: Container( child: Container(
@@ -84,52 +78,50 @@ class SubscriptionSummaryPage extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Montant principal
// Icône principale
Container( Container(
width: 90, width: 80,
height: 90, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15), color: Colors.white.withOpacity(0.15),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(color: Colors.white.withOpacity(0.4), width: 2),
color: Colors.white.withOpacity(0.4), width: 2),
), ),
child: const Icon(Icons.receipt_long_rounded, child: const Icon(Icons.receipt_long_rounded, color: Colors.white, size: 40),
color: Colors.white, size: 44),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// Montant
Text( Text(
_formatPrix(montant), _formatPrix(montant),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 40, fontSize: 42,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: -1, letterSpacing: -1,
), ),
), ),
const Text( const Text(
'FCFA à régler', 'FCFA à régler',
style: TextStyle( style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w500),
), ),
// Badge remise
if (remise != null) ...[ if (remise != null) ...[
const SizedBox(height: 8), const SizedBox(height: 10),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
horizontal: 12, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.gold.withOpacity(0.3), color: UnionFlowColors.gold.withOpacity(0.3),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(color: UnionFlowColors.goldLight.withOpacity(0.5)),
color: UnionFlowColors.goldLight.withOpacity(0.5)),
), ),
child: Text( child: Text(
remise, '🎉 $remise appliquée',
style: const TextStyle( style: const TextStyle(
color: UnionFlowColors.goldLight, color: UnionFlowColors.goldLight,
fontSize: 12, fontSize: 13,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@@ -141,28 +133,26 @@ class SubscriptionSummaryPage extends StatelessWidget {
), ),
), ),
// Content // ── Contenu scrollable ──────────────────────────────
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Organisation // Organisation
if (souscription.organisationNom != null) ...[ if (souscription.organisationNom != null) ...[
_DetailCard( _DetailCard(
title: 'Organisation', title: 'Organisation',
icon: Icons.business_rounded, icon: Icons.business_rounded,
iconColor: UnionFlowColors.indigo, iconColor: UnionFlowColors.indigo,
isDark: isDark,
items: [ items: [
_DetailItem( _DetailItem(label: 'Nom', value: souscription.organisationNom!, bold: true),
label: 'Nom', if (souscription.typeOrganisation != null)
value: souscription.organisationNom!, _DetailItem(label: 'Type', value: souscription.typeOrganisation!),
bold: true),
_DetailItem(
label: 'Type',
value: _orgLabels[souscription.typeOrganisation] ??
souscription.typeOrganisation),
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -173,20 +163,18 @@ class SubscriptionSummaryPage extends StatelessWidget {
title: 'Formule souscrite', title: 'Formule souscrite',
icon: Icons.workspace_premium_rounded, icon: Icons.workspace_premium_rounded,
iconColor: UnionFlowColors.gold, iconColor: UnionFlowColors.gold,
isDark: isDark,
items: [ items: [
_DetailItem(label: 'Niveau', value: souscription.typeFormule, bold: true),
_DetailItem( _DetailItem(
label: 'Niveau', label: 'Capacité',
value: souscription.typeFormule, value: _plageLabels[souscription.plageMembres] ?? souscription.plageLibelle,
bold: true), ),
_DetailItem(
label: 'Taille',
value: _plageLabels[souscription.plageMembres] ??
souscription.plageLibelle),
if (souscription.montantMensuelBase != null) if (souscription.montantMensuelBase != null)
_DetailItem( _DetailItem(
label: 'Prix de base', label: 'Prix de base',
value: value: '${_formatPrix(souscription.montantMensuelBase!)} FCFA/mois',
'${_formatPrix(souscription.montantMensuelBase!)} FCFA/mois'), ),
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -196,71 +184,86 @@ class SubscriptionSummaryPage extends StatelessWidget {
title: 'Facturation', title: 'Facturation',
icon: Icons.calendar_today_rounded, icon: Icons.calendar_today_rounded,
iconColor: UnionFlowColors.unionGreen, iconColor: UnionFlowColors.unionGreen,
isDark: isDark,
items: [ items: [
_DetailItem( _DetailItem(
label: 'Période', label: 'Période',
value: value: _periodeLabels[souscription.typePeriode] ?? souscription.typePeriode,
_periodeLabels[souscription.typePeriode] ?? ),
souscription.typePeriode),
if (souscription.coefficientApplique != null) if (souscription.coefficientApplique != null)
_DetailItem( _DetailItem(
label: 'Coefficient', label: 'Coefficient',
value: value: '×${souscription.coefficientApplique!.toStringAsFixed(4)}',
'×${souscription.coefficientApplique!.toStringAsFixed(4)}'), ),
if (souscription.dateDebut != null && if (souscription.dateDebut != null)
souscription.dateFin != null) ...[ _DetailItem(label: 'Début', value: _formatDate(souscription.dateDebut!)),
_DetailItem( if (souscription.dateFin != null)
label: 'Début', _DetailItem(label: 'Fin', value: _formatDate(souscription.dateFin!)),
value: _formatDate(souscription.dateDebut!)),
_DetailItem(
label: 'Fin',
value: _formatDate(souscription.dateFin!)),
],
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 20),
// Montant total // Bloc montant total — proéminent
Container( Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(color: UnionFlowColors.gold.withOpacity(0.4)),
color: UnionFlowColors.gold.withOpacity(0.4)), boxShadow: isDark ? null : UnionFlowColors.goldGlowShadow,
boxShadow: UnionFlowColors.goldGlowShadow,
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 48, width: 52,
height: 48, height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: UnionFlowColors.goldGradient, gradient: UnionFlowColors.goldGradient,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
), ),
child: const Icon(Icons.monetization_on_rounded, 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( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'Total à payer', 'TOTAL À PAYER',
style: TextStyle( style: TextStyle(
color: UnionFlowColors.textSecondary, color: isDark
fontSize: 13), ? AppColors.textSecondaryDark
: AppColors.textSecondary,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
), ),
Text( Text(
'${_formatPrix(montant)} FCFA', '${_formatPrix(montant)} FCFA',
style: const TextStyle( style: TextStyle(
color: UnionFlowColors.textPrimary, color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary,
fontSize: 22, fontSize: 24,
fontWeight: FontWeight.w900, 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), const SizedBox(height: 20),
// Notes importantes // Notes
_NoteBox( _NoteBox(
icon: Icons.security_rounded, icon: Icons.security_rounded,
iconColor: UnionFlowColors.unionGreen, iconColor: UnionFlowColors.unionGreen,
backgroundColor: UnionFlowColors.unionGreenPale, accentColor: UnionFlowColors.unionGreen,
borderColor: UnionFlowColors.unionGreen.withOpacity(0.25), isDark: isDark,
title: 'Paiement sécurisé', title: 'Paiement sécurisé',
message: message: 'Votre paiement est traité de manière sécurisée via Wave Mobile Money. Une fois confirmé, votre compte sera activé automatiquement.',
'Votre paiement est traité de manière sécurisée via Wave Mobile Money. Une fois le paiement effectué, votre compte sera activé automatiquement.',
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_NoteBox( _NoteBox(
icon: Icons.bolt_rounded, icon: Icons.bolt_rounded,
iconColor: UnionFlowColors.amber, iconColor: UnionFlowColors.amber,
backgroundColor: const Color(0xFFFFFBF0), accentColor: UnionFlowColors.amber,
borderColor: UnionFlowColors.amber.withOpacity(0.3), isDark: isDark,
title: 'Activation immédiate', title: 'Activation immédiate',
message: message: 'Dès que Wave confirme le paiement, votre espace administrateur est activé avec toutes les fonctionnalités de votre formule.',
'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.',
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_NoteBox( _NoteBox(
icon: Icons.support_agent_rounded, icon: Icons.support_agent_rounded,
iconColor: UnionFlowColors.info, iconColor: UnionFlowColors.info,
backgroundColor: UnionFlowColors.infoPale, accentColor: UnionFlowColors.info,
borderColor: UnionFlowColors.info.withOpacity(0.2), isDark: isDark,
title: 'Besoin d\'aide ?', title: 'Besoin d\'aide ?',
message: message: 'En cas de problème, contactez support@unionflow.app — réponse sous 24h.',
'En cas de problème lors du paiement, contactez notre support à support@unionflow.app — nous vous répondrons sous 24h.',
), ),
], ],
), ),
@@ -305,41 +305,10 @@ class SubscriptionSummaryPage extends StatelessWidget {
), ),
], ],
), ),
bottomNavigationBar: Container( bottomNavigationBar: OnboardingBottomBar(
padding: EdgeInsets.fromLTRB( enabled: true,
20, 12, 20, MediaQuery.of(context).padding.bottom + 12), label: 'Choisir le moyen de paiement',
decoration: BoxDecoration( onPressed: () => context.read<OnboardingBloc>().add(const OnboardingChoixPaiementOuvert()),
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<OnboardingBloc>()
.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,
),
),
),
), ),
); );
} }
@@ -347,18 +316,13 @@ class SubscriptionSummaryPage extends StatelessWidget {
String _formatPrix(double prix) { String _formatPrix(double prix) {
if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M'; if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
final s = prix.toStringAsFixed(0); final s = prix.toStringAsFixed(0);
if (s.length > 6) { if (s.length > 6) return '${s.substring(0, s.length - 6)} ${s.substring(s.length - 6, s.length - 3)} ${s.substring(s.length - 3)}';
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 > 3) {
return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}';
}
return s; return s;
} }
String _formatDate(DateTime date) { String _formatDate(DateTime date) =>
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
} }
// ─── Widgets locaux ────────────────────────────────────────────────────────── // ─── Widgets locaux ──────────────────────────────────────────────────────────
@@ -367,8 +331,7 @@ class _DetailItem {
final String label; final String label;
final String value; final String value;
final bool bold; final bool bold;
const _DetailItem( const _DetailItem({required this.label, required this.value, this.bold = false});
{required this.label, required this.value, this.bold = false});
} }
class _DetailCard extends StatelessWidget { class _DetailCard extends StatelessWidget {
@@ -376,25 +339,34 @@ class _DetailCard extends StatelessWidget {
final IconData icon; final IconData icon;
final Color iconColor; final Color iconColor;
final List<_DetailItem> items; final List<_DetailItem> items;
final bool isDark;
const _DetailCard({ const _DetailCard({
required this.title, required this.title,
required this.icon, required this.icon,
required this.iconColor, required this.iconColor,
required this.items, required this.items,
required this.isDark,
}); });
@override @override
Widget build(BuildContext context) { 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( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: UnionFlowColors.surface, color: bgColor,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow, border: Border.all(color: borderColor),
boxShadow: isDark ? null : UnionFlowColors.softShadow,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// En-tête section
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Row( child: Row(
@@ -403,7 +375,7 @@ class _DetailCard extends StatelessWidget {
width: 34, width: 34,
height: 34, height: 34,
decoration: BoxDecoration( decoration: BoxDecoration(
color: iconColor.withOpacity(0.1), color: iconColor.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(icon, color: iconColor, size: 18), child: Icon(icon, color: iconColor, size: 18),
@@ -411,16 +383,16 @@ class _DetailCard extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
title, title,
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 14, fontSize: 14,
color: UnionFlowColors.textPrimary, color: textPrimary,
), ),
), ),
], ],
), ),
), ),
const Divider(height: 1, color: UnionFlowColors.border), Divider(height: 1, color: borderColor),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
child: Column( child: Column(
@@ -430,23 +402,17 @@ class _DetailCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
width: 120, width: 110,
child: Text( child: Text(item.label,
item.label, style: TextStyle(color: textSecondary, fontSize: 13)),
style: const TextStyle(
color: UnionFlowColors.textSecondary,
fontSize: 13),
),
), ),
Expanded( Expanded(
child: Text( child: Text(
item.value, item.value,
style: TextStyle( style: TextStyle(
color: UnionFlowColors.textPrimary, color: textPrimary,
fontSize: 13, fontSize: 13,
fontWeight: item.bold fontWeight: item.bold ? FontWeight.w700 : FontWeight.w500,
? FontWeight.w700
: FontWeight.w500,
), ),
), ),
), ),
@@ -464,26 +430,30 @@ class _DetailCard extends StatelessWidget {
class _NoteBox extends StatelessWidget { class _NoteBox extends StatelessWidget {
final IconData icon; final IconData icon;
final Color iconColor; final Color iconColor;
final Color backgroundColor; final Color accentColor;
final Color borderColor; final bool isDark;
final String title; final String title;
final String message; final String message;
const _NoteBox({ const _NoteBox({
required this.icon, required this.icon,
required this.iconColor, required this.iconColor,
required this.backgroundColor, required this.accentColor,
required this.borderColor, required this.isDark,
required this.title, required this.title,
required this.message, required this.message,
}); });
@override @override
Widget build(BuildContext context) { 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( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: bgColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor), border: Border.all(color: borderColor),
), ),
@@ -498,19 +468,12 @@ class _NoteBox extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: TextStyle( style: TextStyle(color: iconColor, fontWeight: FontWeight.w700, fontSize: 13),
color: iconColor,
fontWeight: FontWeight.w700,
fontSize: 13,
),
), ),
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
message, message,
style: const TextStyle( style: TextStyle(color: textSecondary, fontSize: 12, height: 1.5),
color: UnionFlowColors.textSecondary,
fontSize: 12,
height: 1.5),
), ),
], ],
), ),

View File

@@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../bloc/onboarding_bloc.dart'; import '../../bloc/onboarding_bloc.dart';
import '../../data/models/souscription_status_model.dart'; import '../../data/models/souscription_status_model.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart'; import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../../../core/config/environment.dart'; import '../../../../core/config/environment.dart';
/// Étape 4 — Lancement du paiement Wave + attente du retour /// Étape 4 — Lancement du paiement Wave + attente du retour
@@ -33,6 +34,9 @@ class _WavePaymentPageState extends State<WavePaymentPage>
widget.waveLaunchUrl.contains('localhost') || widget.waveLaunchUrl.contains('localhost') ||
!AppConfig.isProd; !AppConfig.isProd;
// Couleur de marque Wave (volontairement hardcodée)
static const _waveBlue = Color(0xFF00B9F1);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -72,10 +76,10 @@ class _WavePaymentPageState extends State<WavePaymentPage>
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text( content: const Text(
'Impossible d\'ouvrir Wave. Vérifiez que l\'application est installée.'), 'Impossible d\'ouvrir Wave. Vérifiez que l\'application est installée.'),
backgroundColor: UnionFlowColors.error, backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
@@ -85,336 +89,354 @@ class _WavePaymentPageState extends State<WavePaymentPage>
@override @override
Widget build(BuildContext context) { 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; final montant = widget.souscription.montantTotal ?? 0;
const waveBlue = Color(0xFF00B9F1);
return Scaffold( return BlocListener<OnboardingBloc, OnboardingState>(
backgroundColor: UnionFlowColors.background, listener: (context, state) {
body: SafeArea( // Afficher snackbar si la confirmation a échoué
child: Padding( if (state is OnboardingPaiementEchoue) {
padding: const EdgeInsets.all(24), setState(() {
child: Column( _paymentLaunched = false;
children: [ _appResumed = false;
Row( _simulating = false;
children: [ });
if (!_paymentLaunched && !_simulating) ScaffoldMessenger.of(context).showSnackBar(
IconButton( SnackBar(
onPressed: () => Navigator.of(context).maybePop(), content: Row(children: [
icon: const Icon(Icons.arrow_back_rounded), const Icon(Icons.error_outline, color: Colors.white, size: 18),
color: UnionFlowColors.textSecondary, const SizedBox(width: 8),
), Expanded(child: Text(state.message)),
const Spacer(), ]),
if (_isMock) backgroundColor: AppColors.error,
Container( behavior: SnackBarBehavior.floating,
padding: const EdgeInsets.symmetric( duration: const Duration(seconds: 5),
horizontal: 10, vertical: 4), ),
decoration: BoxDecoration( );
color: UnionFlowColors.warningPale, }
borderRadius: BorderRadius.circular(20), },
border: Border.all( child: Scaffold(
color: UnionFlowColors.warning.withOpacity(0.4)), backgroundColor: Theme.of(context).scaffoldBackgroundColor,
), body: SafeArea(
child: const Row( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ child: Column(
Icon(Icons.science_rounded, children: [
size: 13, color: UnionFlowColors.warning), // ── Top bar ───────────────────────────────────
SizedBox(width: 4), Row(
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,
children: [ children: [
if (_simulating) ...[ if (!_paymentLaunched && !_simulating)
// Animation de simulation IconButton(
Container( onPressed: () => Navigator.of(context).maybePop(),
width: 110, icon: const Icon(Icons.arrow_back_rounded),
height: 110, color: textSecondary,
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), const Spacer(),
const Text( if (_isMock)
'Simulation du paiement…', _buildDevBadge()
style: TextStyle( else
fontSize: 20, _buildWaveBadge(),
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),
),
],
],
], ],
), ),
// ── 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),
),
],
],
); );
} }