Files
unionflow-mobile-apps/lib/features/onboarding/presentation/pages/plan_selection_page.dart
dahoud 70cbd1c873 fix(mobile): URL changement mdp corrigée + v3.0 — multi-org, AppAuth, sécurité prod
Auth:
- profile_repository.dart: /api/auth/change-password → /api/membres/auth/change-password

Multi-org (Phase 3):
- OrgSelectorPage, OrgSwitcherBloc, OrgSwitcherEntry
- org_context_service.dart: headers X-Active-Organisation-Id + X-Active-Role

Navigation:
- MorePage: navigation conditionnelle par typeOrganisation
- Suppression adaptive_navigation (remplacé par main_navigation_layout)

Auth AppAuth:
- keycloak_webview_auth_service: fixes AppAuth Android
- AuthBloc: gestion REAUTH_REQUIS + premierLoginComplet

Onboarding:
- Nouveaux états: payment_method_page, onboarding_shared_widgets
- SouscriptionStatusModel mis à jour StatutValidationSouscription

Android:
- build.gradle: ProGuard/R8, network_security_config
- Gradle wrapper mis à jour
2026-04-07 20:56:03 +00:00

381 lines
14 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/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;
static const _plages = [
_Plage('PETITE', 'Petite', '1 100 membres', Icons.group_outlined, 'Associations naissantes et petites structures'),
_Plage('MOYENNE', 'Moyenne', '101 500 membres', Icons.groups_outlined, 'Associations établies en croissance'),
_Plage('GRANDE', 'Grande', '501 2 000 membres', Icons.corporate_fare_outlined, 'Grandes organisations régionales'),
_Plage('TRES_GRANDE', 'Très grande', '2 000+ membres', Icons.account_balance_outlined, 'Fédérations et réseaux nationaux'),
];
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
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: UnionFlowColors.background,
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(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Step 1a: Taille de l'organisation
OnboardingSectionTitle(
icon: Icons.people_alt_outlined,
title: 'Taille de votre organisation',
),
const SizedBox(height: 12),
...(_plages.map((p) => _PlageCard(
plage: p,
selected: _selectedPlage == p.code,
onTap: () => setState(() {
_selectedPlage = p.code;
_selectedFormule = null;
}),
))),
if (_selectedPlage != null) ...[
const SizedBox(height: 28),
OnboardingSectionTitle(
icon: Icons.workspace_premium_outlined,
title: 'Niveau d\'abonnement',
),
const SizedBox(height: 12),
..._filteredFormules.map((f) => _FormuleCard(
formule: f,
color: _formuleColors[f.code] ?? UnionFlowColors.unionGreen,
icon: _formuleIcons[f.code] ?? Icons.star_border_rounded,
features: _formuleFeatures[f.code] ?? [],
selected: _selectedFormule == f.code,
onTap: () => setState(() => _selectedFormule = f.code),
)),
],
],
),
),
),
],
),
bottomNavigationBar: OnboardingBottomBar(
enabled: _canProceed,
label: 'Choisir la période',
onPressed: () => context.read<OnboardingBloc>().add(
OnboardingFormuleSelected(
codeFormule: _selectedFormule!,
plage: _selectedPlage!,
),
),
),
);
}
}
// ─── Widgets locaux ──────────────────────────────────────────────────────────
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 VoidCallback onTap;
const _PlageCard({required this.plage, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: selected ? UnionFlowColors.unionGreenPale : UnionFlowColors.surface,
border: Border.all(
color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border,
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(14),
boxShadow: selected ? UnionFlowColors.greenGlowShadow : UnionFlowColors.softShadow,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.unionGreenPale,
borderRadius: BorderRadius.circular(10),
),
child: Icon(plage.icon,
color: selected ? Colors.white : UnionFlowColors.unionGreen,
size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
plage.label,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.textPrimary,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: selected
? UnionFlowColors.unionGreen.withOpacity(0.15)
: UnionFlowColors.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: Text(
plage.sublabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 2),
Text(
plage.description,
style: const TextStyle(
fontSize: 12,
color: UnionFlowColors.textSecondary),
),
],
),
),
Icon(
selected
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.border,
size: 22,
),
],
),
),
),
);
}
}
class _FormuleCard extends StatelessWidget {
final FormuleModel formule;
final Color color;
final IconData icon;
final List<String> features;
final bool selected;
final VoidCallback onTap;
const _FormuleCard({
required this.formule,
required this.color,
required this.icon,
required this.features,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
border: Border.all(
color: selected ? color : UnionFlowColors.border,
width: selected ? 2.5 : 1,
),
borderRadius: BorderRadius.circular(16),
boxShadow: selected
? [
BoxShadow(
color: color.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 8),
)
]
: UnionFlowColors.softShadow,
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: selected ? color : color.withOpacity(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: [
Text(
formule.libelle,
style: TextStyle(
color: selected ? Colors.white : color,
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
if (formule.description != null)
Text(
formule.description!,
style: TextStyle(
color: selected
? Colors.white70
: UnionFlowColors.textSecondary,
fontSize: 12,
),
),
],
),
),
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
: UnionFlowColors.textSecondary,
fontSize: 11,
),
),
],
),
],
),
),
// Features
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
child: Column(
children: features
.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Icon(Icons.check_circle_outline_rounded,
size: 16, color: color),
const SizedBox(width: 8),
Text(f,
style: const TextStyle(
fontSize: 13,
color: UnionFlowColors.textPrimary)),
],
),
))
.toList(),
),
),
],
),
),
);
}
String _formatPrix(double prix) {
if (prix >= 1000000) {
return '${(prix / 1000000).toStringAsFixed(1)} M';
}
if (prix >= 1000) {
final k = (prix / 1000).toStringAsFixed(0);
return '$k 000';
}
return prix.toStringAsFixed(0);
}
}