Files
unionflow-mobile-apps/lib/features/onboarding/presentation/pages/subscription_summary_page.dart
dahoud 21b519de53 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
2026-04-15 20:14:27 +00:00

486 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/onboarding_bloc.dart';
import '../../data/models/souscription_status_model.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import 'onboarding_shared_widgets.dart';
/// Étape 3 — Récapitulatif détaillé avant paiement
class SubscriptionSummaryPage extends StatelessWidget {
final SouscriptionStatusModel souscription;
const SubscriptionSummaryPage({super.key, required this.souscription});
static const _periodeLabels = {
'MENSUEL': 'Mensuel',
'TRIMESTRIEL': 'Trimestriel',
'SEMESTRIEL': 'Semestriel',
'ANNUEL': 'Annuel',
};
static const _periodeRemises = {
'MENSUEL': null,
'TRIMESTRIEL': '5 % de remise',
'SEMESTRIEL': '10 % de remise',
'ANNUEL': '20 % de remise',
};
static const _plageLabels = {
'PETITE': '1 100 membres',
'MOYENNE': '101 500 membres',
'GRANDE': '501 2 000 membres',
'TRES_GRANDE': '2 000+ membres',
};
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final montant = souscription.montantTotal ?? 0;
final remise = _periodeRemises[souscription.typePeriode];
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column(
children: [
// ── Header hero gradient ───────────────────────────
Container(
decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
child: Column(
children: [
// Barre de progression — toutes complètes à l'étape 3
Row(
children: List.generate(3, (i) => Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(right: i < 2 ? 6 : 0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2),
),
),
)),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Étape 3 sur 3',
style: TextStyle(
color: Colors.white.withOpacity(0.75),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 20),
// Icône principale
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.4), width: 2),
),
child: const Icon(Icons.receipt_long_rounded, color: Colors.white, size: 40),
),
const SizedBox(height: 14),
// Montant
Text(
_formatPrix(montant),
style: const TextStyle(
color: Colors.white,
fontSize: 42,
fontWeight: FontWeight.w900,
letterSpacing: -1,
),
),
const Text(
'FCFA à régler',
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
),
// Badge remise
if (remise != null) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
decoration: BoxDecoration(
color: UnionFlowColors.gold.withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: UnionFlowColors.goldLight.withOpacity(0.5)),
),
child: Text(
'🎉 $remise appliquée',
style: const TextStyle(
color: UnionFlowColors.goldLight,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
],
],
),
),
),
),
// ── Contenu scrollable ──────────────────────────────
Expanded(
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Organisation
if (souscription.organisationNom != null) ...[
_DetailCard(
title: 'Organisation',
icon: Icons.business_rounded,
iconColor: UnionFlowColors.indigo,
isDark: isDark,
items: [
_DetailItem(label: 'Nom', value: souscription.organisationNom!, bold: true),
if (souscription.typeOrganisation != null)
_DetailItem(label: 'Type', value: souscription.typeOrganisation!),
],
),
const SizedBox(height: 14),
],
// Formule
_DetailCard(
title: 'Formule souscrite',
icon: Icons.workspace_premium_rounded,
iconColor: UnionFlowColors.gold,
isDark: isDark,
items: [
_DetailItem(label: 'Niveau', value: souscription.typeFormule, bold: true),
_DetailItem(
label: 'Capacité',
value: _plageLabels[souscription.plageMembres] ?? souscription.plageLibelle,
),
if (souscription.montantMensuelBase != null)
_DetailItem(
label: 'Prix de base',
value: '${_formatPrix(souscription.montantMensuelBase!)} FCFA/mois',
),
],
),
const SizedBox(height: 14),
// Facturation
_DetailCard(
title: 'Facturation',
icon: Icons.calendar_today_rounded,
iconColor: UnionFlowColors.unionGreen,
isDark: isDark,
items: [
_DetailItem(
label: 'Période',
value: _periodeLabels[souscription.typePeriode] ?? souscription.typePeriode,
),
if (souscription.coefficientApplique != null)
_DetailItem(
label: 'Coefficient',
value: '×${souscription.coefficientApplique!.toStringAsFixed(4)}',
),
if (souscription.dateDebut != null)
_DetailItem(label: 'Début', value: _formatDate(souscription.dateDebut!)),
if (souscription.dateFin != null)
_DetailItem(label: 'Fin', value: _formatDate(souscription.dateFin!)),
],
),
const SizedBox(height: 20),
// Bloc montant total — proéminent
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [UnionFlowColors.gold.withOpacity(0.18), UnionFlowColors.amber.withOpacity(0.12)]
: [UnionFlowColors.goldPale, const Color(0xFFFFF3C8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: UnionFlowColors.gold.withOpacity(0.4)),
boxShadow: isDark ? null : UnionFlowColors.goldGlowShadow,
),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: UnionFlowColors.goldGradient,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(Icons.monetization_on_rounded,
color: Colors.white, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'TOTAL À PAYER',
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondary,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
Text(
'${_formatPrix(montant)} FCFA',
style: TextStyle(
color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.w900,
letterSpacing: -0.5,
),
),
if (remise != null)
Text(
remise,
style: const TextStyle(
color: UnionFlowColors.gold,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const SizedBox(height: 20),
// Notes
_NoteBox(
icon: Icons.security_rounded,
iconColor: UnionFlowColors.unionGreen,
accentColor: UnionFlowColors.unionGreen,
isDark: isDark,
title: 'Paiement sécurisé',
message: 'Votre paiement est traité de manière sécurisée via Wave Mobile Money. Une fois confirmé, votre compte sera activé automatiquement.',
),
const SizedBox(height: 10),
_NoteBox(
icon: Icons.bolt_rounded,
iconColor: UnionFlowColors.amber,
accentColor: UnionFlowColors.amber,
isDark: isDark,
title: 'Activation immédiate',
message: 'Dès que Wave confirme le paiement, votre espace administrateur est activé avec toutes les fonctionnalités de votre formule.',
),
const SizedBox(height: 10),
_NoteBox(
icon: Icons.support_agent_rounded,
iconColor: UnionFlowColors.info,
accentColor: UnionFlowColors.info,
isDark: isDark,
title: 'Besoin d\'aide ?',
message: 'En cas de problème, contactez support@unionflow.app — réponse sous 24h.',
),
],
),
),
),
],
),
bottomNavigationBar: OnboardingBottomBar(
enabled: true,
label: 'Choisir le moyen de paiement',
onPressed: () => context.read<OnboardingBloc>().add(const OnboardingChoixPaiementOuvert()),
),
);
}
String _formatPrix(double prix) {
if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
final s = prix.toStringAsFixed(0);
if (s.length > 6) return '${s.substring(0, s.length - 6)} ${s.substring(s.length - 6, s.length - 3)} ${s.substring(s.length - 3)}';
if (s.length > 3) return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}';
return s;
}
String _formatDate(DateTime date) =>
'${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
// ─── Widgets locaux ──────────────────────────────────────────────────────────
class _DetailItem {
final String label;
final String value;
final bool bold;
const _DetailItem({required this.label, required this.value, this.bold = false});
}
class _DetailCard extends StatelessWidget {
final String title;
final IconData icon;
final Color iconColor;
final List<_DetailItem> items;
final bool isDark;
const _DetailCard({
required this.title,
required this.icon,
required this.iconColor,
required this.items,
required this.isDark,
});
@override
Widget build(BuildContext context) {
final bgColor = isDark ? AppColors.surfaceDark : AppColors.surface;
final borderColor = isDark ? AppColors.borderDark : AppColors.border;
final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary;
final textSecondary= isDark ? AppColors.textSecondaryDark : AppColors.textSecondary;
return Container(
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: borderColor),
boxShadow: isDark ? null : UnionFlowColors.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête section
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: iconColor.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: iconColor, size: 18),
),
const SizedBox(width: 10),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
color: textPrimary,
),
),
],
),
),
Divider(height: 1, color: borderColor),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
child: Column(
children: items.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(item.label,
style: TextStyle(color: textSecondary, fontSize: 13)),
),
Expanded(
child: Text(
item.value,
style: TextStyle(
color: textPrimary,
fontSize: 13,
fontWeight: item.bold ? FontWeight.w700 : FontWeight.w500,
),
),
),
],
),
)).toList(),
),
),
],
),
);
}
}
class _NoteBox extends StatelessWidget {
final IconData icon;
final Color iconColor;
final Color accentColor;
final bool isDark;
final String title;
final String message;
const _NoteBox({
required this.icon,
required this.iconColor,
required this.accentColor,
required this.isDark,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
final bgColor = accentColor.withOpacity(isDark ? 0.12 : 0.06);
final borderColor = accentColor.withOpacity(isDark ? 0.3 : 0.2);
final textSecondary= isDark ? AppColors.textSecondaryDark : AppColors.textSecondary;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: iconColor, fontWeight: FontWeight.w700, fontSize: 13),
),
const SizedBox(height: 3),
Text(
message,
style: TextStyle(color: textSecondary, fontSize: 12, height: 1.5),
),
],
),
),
],
),
);
}
}