Files
unionflow-mobile-apps/lib/features/onboarding/presentation/pages/plan_selection_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

534 lines
21 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/formule_model.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import 'onboarding_shared_widgets.dart';
/// Étape 1 — Choix de la taille de l'organisation et du niveau de formule
class PlanSelectionPage extends StatefulWidget {
final List<FormuleModel> formules;
const PlanSelectionPage({super.key, required this.formules});
@override
State<PlanSelectionPage> createState() => _PlanSelectionPageState();
}
class _PlanSelectionPageState extends State<PlanSelectionPage> {
String? _selectedPlage;
String? _selectedFormule;
final _scrollController = ScrollController();
static const _plages = [
_Plage('PETITE', 'Petite', '1 100', Icons.group_outlined, 'Associations naissantes'),
_Plage('MOYENNE', 'Moyenne', '101 500', Icons.groups_outlined, 'Organisations établies'),
_Plage('GRANDE', 'Grande', '501 2 000', Icons.corporate_fare_outlined, 'Grandes structures'),
_Plage('TRES_GRANDE', 'Très grande', '2 000+', Icons.account_balance_outlined, 'Fédérations & réseaux'),
];
static const _formuleColors = {
'BASIC': UnionFlowColors.unionGreen,
'STANDARD': UnionFlowColors.gold,
'PREMIUM': UnionFlowColors.indigo,
};
static const _formuleIcons = {
'BASIC': Icons.star_border_rounded,
'STANDARD': Icons.star_half_rounded,
'PREMIUM': Icons.star_rounded,
};
static const _formuleFeatures = {
'BASIC': ['Gestion des membres', 'Cotisations de base', 'Rapports mensuels', 'Support email'],
'STANDARD': ['Tout Basic +', 'Événements & solidarité', 'Communication interne', 'Tableaux de bord avancés', 'Support prioritaire'],
'PREMIUM': ['Tout Standard +', 'Multi-organisations', 'Analytics temps réel', 'API ouverte', 'Support dédié 24/7'],
};
List<FormuleModel> get _filteredFormules => widget.formules
.where((f) => _selectedPlage == null || f.plage == _selectedPlage)
.toList()
..sort((a, b) => a.ordreAffichage.compareTo(b.ordreAffichage));
bool get _canProceed => _selectedPlage != null && _selectedFormule != null;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onPlageSelected(String code) {
setState(() {
_selectedPlage = code;
_selectedFormule = null;
});
// Scroll vers la section formules après la frame courante
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
);
}
});
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column(
children: [
OnboardingStepHeader(
step: 1,
total: 3,
title: 'Choisissez votre formule',
subtitle: 'Sélectionnez la taille de votre organisation\npuis le niveau d\'abonnement adapté.',
),
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Section 1 : Taille ────────────────────────────────
OnboardingSectionTitle(
icon: Icons.people_alt_outlined,
title: 'Taille de votre organisation',
badge: _selectedPlage != null ? 'Sélectionné ✓' : null,
),
const SizedBox(height: 12),
// Grille 2×2 pour scanner rapidement
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.45,
children: _plages.map((p) => _PlageCard(
plage: p,
selected: _selectedPlage == p.code,
isDark: isDark,
onTap: () => _onPlageSelected(p.code),
)).toList(),
),
// ── Section 2 : Formule (apparaît en fondu) ──────────
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)),
child: child,
),
),
child: _selectedPlage == null
? const SizedBox.shrink()
: Column(
key: ValueKey(_selectedPlage),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 28),
OnboardingSectionTitle(
icon: Icons.workspace_premium_outlined,
title: 'Niveau d\'abonnement',
badge: _selectedFormule != null ? 'Sélectionné ✓' : null,
),
const SizedBox(height: 4),
Text(
'Modifiable à tout moment depuis vos paramètres.',
style: TextStyle(
fontSize: 11,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondary,
),
),
const SizedBox(height: 14),
..._filteredFormules.map((f) => _FormuleCard(
formule: f,
color: _formuleColors[f.code] ?? UnionFlowColors.unionGreen,
icon: _formuleIcons[f.code] ?? Icons.star_border_rounded,
features: _formuleFeatures[f.code] ?? [],
selected: _selectedFormule == f.code,
isPopular: f.code == 'STANDARD',
isDark: isDark,
onTap: () => setState(() => _selectedFormule = f.code),
)),
],
),
),
],
),
),
),
],
),
bottomNavigationBar: OnboardingBottomBar(
enabled: _canProceed,
label: 'Choisir la période →',
hint: _canProceed ? null : 'Sélectionnez une taille et une formule pour continuer',
onPressed: () => context.read<OnboardingBloc>().add(
OnboardingFormuleSelected(
codeFormule: _selectedFormule!,
plage: _selectedPlage!,
),
),
),
);
}
}
// ─── Carte de taille (grille 2×2) ────────────────────────────────────────────
class _Plage {
final String code, label, sublabel, description;
final IconData icon;
const _Plage(this.code, this.label, this.sublabel, this.icon, this.description);
}
class _PlageCard extends StatelessWidget {
final _Plage plage;
final bool selected;
final bool isDark;
final VoidCallback onTap;
const _PlageCard({
required this.plage,
required this.selected,
required this.isDark,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final bgColor = isDark ? AppColors.surfaceDark : AppColors.surface;
final borderColor = isDark ? AppColors.borderDark : AppColors.border;
final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary;
final textSecondary= isDark ? AppColors.textSecondaryDark : AppColors.textSecondary;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
decoration: BoxDecoration(
color: selected
? UnionFlowColors.unionGreen.withOpacity(isDark ? 0.15 : 0.06)
: bgColor,
border: Border.all(
color: selected ? UnionFlowColors.unionGreen : borderColor,
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(14),
boxShadow: selected ? UnionFlowColors.greenGlowShadow : null,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.unionGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(9),
),
child: Icon(
plage.icon,
color: selected ? Colors.white : UnionFlowColors.unionGreen,
size: 19,
),
),
Icon(
selected
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
color: selected ? UnionFlowColors.unionGreen : borderColor,
size: 18,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
plage.label,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
color: selected ? UnionFlowColors.unionGreen : textPrimary,
),
),
const SizedBox(height: 1),
Text(
'${plage.sublabel} membres',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: selected
? UnionFlowColors.unionGreen.withOpacity(0.8)
: textSecondary,
),
),
Text(
plage.description,
style: TextStyle(
fontSize: 9,
color: textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
),
);
}
}
// ─── Carte de formule ─────────────────────────────────────────────────────────
class _FormuleCard extends StatelessWidget {
final FormuleModel formule;
final Color color;
final IconData icon;
final List<String> features;
final bool selected;
final bool isPopular;
final bool isDark;
final VoidCallback onTap;
const _FormuleCard({
required this.formule,
required this.color,
required this.icon,
required this.features,
required this.selected,
required this.isPopular,
required this.isDark,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final bgCard = isDark ? AppColors.surfaceDark : AppColors.surface;
final borderColor = isDark ? AppColors.borderDark : AppColors.border;
final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary;
final textSecondary= isDark ? AppColors.textSecondaryDark : AppColors.textSecondary;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: bgCard,
border: Border.all(
color: selected ? color : borderColor,
width: selected ? 2.5 : 1,
),
borderRadius: BorderRadius.circular(16),
boxShadow: selected
? [BoxShadow(color: color.withOpacity(isDark ? 0.25 : 0.18), blurRadius: 20, offset: const Offset(0, 8))]
: isDark ? null : UnionFlowColors.softShadow,
),
child: Column(
children: [
// ── Header coloré ─────────────────────────────────
Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: selected ? color : color.withOpacity(isDark ? 0.15 : 0.06),
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
),
child: Row(
children: [
Icon(icon,
color: selected ? Colors.white : color,
size: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Wrap permet au badge de passer à la ligne sur écrans étroits
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
formule.libelle,
style: TextStyle(
color: selected ? Colors.white : color,
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
if (isPopular)
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: selected
? Colors.white.withOpacity(0.25)
: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'⭐ POPULAIRE',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w800,
color: selected ? Colors.white : color,
letterSpacing: 0.3,
),
),
),
],
),
if (formule.description != null)
Text(
formule.description!,
style: TextStyle(
color: selected ? Colors.white70 : textSecondary,
fontSize: 12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Prix
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatPrix(formule.prixMensuel),
style: TextStyle(
color: selected ? Colors.white : color,
fontWeight: FontWeight.w900,
fontSize: 20,
),
),
Text(
'FCFA/mois',
style: TextStyle(
color: selected ? Colors.white70 : textSecondary,
fontSize: 10,
),
),
if (formule.prixAnnuel != null && formule.prixAnnuel! > 0) ...[
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: selected
? Colors.white.withOpacity(0.2)
: AppColors.success.withOpacity(0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${_annualSavingPct(formule)}% /an',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : AppColors.success,
),
),
),
],
],
),
],
),
),
// ── Features ──────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
child: Column(
children: features.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 7),
child: Row(
children: [
Icon(Icons.check_circle_outline_rounded,
size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
f,
style: TextStyle(fontSize: 13, color: textPrimary),
),
),
],
),
)).toList(),
),
),
// ── Sélection indicator ────────────────────────────
if (selected)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: color.withOpacity(isDark ? 0.2 : 0.08),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_rounded, size: 14, color: color),
const SizedBox(width: 6),
Text(
'Formule sélectionnée',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
],
),
),
],
),
),
);
}
String _formatPrix(double prix) {
if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
if (prix >= 1000) return '${(prix / 1000).toStringAsFixed(0)} k';
return prix.toStringAsFixed(0);
}
String _annualSavingPct(FormuleModel f) {
if (f.prixAnnuel == null || f.prixAnnuel! <= 0 || f.prixMensuel <= 0) return '0';
final monthly12 = f.prixMensuel * 12;
final saving = ((monthly12 - f.prixAnnuel!) / monthly12 * 100).round();
return saving.toString();
}
}