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

423 lines
15 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 '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import 'onboarding_shared_widgets.dart';
/// Étape 2 — Choix de la période de facturation
/// Le type d'organisation est récupéré automatiquement depuis le backend.
class PeriodSelectionPage extends StatefulWidget {
final String codeFormule;
final String plage;
final List<FormuleModel> formules;
const PeriodSelectionPage({
super.key,
required this.codeFormule,
required this.plage,
required this.formules,
});
@override
State<PeriodSelectionPage> createState() => _PeriodSelectionPageState();
}
class _PeriodSelectionPageState extends State<PeriodSelectionPage> {
String _selectedPeriode = 'MENSUEL';
static const _periodes = [
_Periode('MENSUEL', 'Mensuel', '1 mois', null, 1, 1.00),
_Periode('TRIMESTRIEL', 'Trimestriel', '3 mois', '5%', 3, 0.95),
_Periode('SEMESTRIEL', 'Semestriel', '6 mois', '10%', 6, 0.90),
_Periode('ANNUEL', 'Annuel', '12 mois', '20%', 12, 0.80),
];
FormuleModel? get _formule => widget.formules
.where((f) => f.code == widget.codeFormule && f.plage == widget.plage)
.firstOrNull;
double _estimerPrix(String periodeCode) {
final f = _formule;
if (f == null) return 0;
final p = _periodes.firstWhere((x) => x.code == periodeCode);
return f.prixMensuel * p.coef * p.nbMois;
}
String get _organisationId {
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
return authState.user.organizationContexts.isNotEmpty
? authState.user.organizationContexts.first.organizationId
: '';
}
if (authState is AuthPendingOnboarding) {
return authState.organisationId ?? '';
}
return '';
}
@override
Widget build(BuildContext context) {
final prixSelected = _estimerPrix(_selectedPeriode);
return Scaffold(
backgroundColor: UnionFlowColors.background,
body: Column(
children: [
OnboardingStepHeader(
step: 2,
total: 3,
title: 'Période de facturation',
subtitle: 'Choisissez votre rythme de paiement.\nPlus la période est longue, plus vous économisez.',
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Rappel formule sélectionnée
_FormulaRecap(
codeFormule: widget.codeFormule,
plage: widget.plage,
prixMensuel: _formule?.prixMensuel ?? 0,
),
const SizedBox(height: 24),
OnboardingSectionTitle(
icon: Icons.calendar_month_outlined,
title: 'Choisissez votre période',
),
const SizedBox(height: 12),
..._periodes.map((p) => _PeriodeCard(
periode: p,
selected: _selectedPeriode == p.code,
prixTotal: _estimerPrix(p.code),
onTap: () => setState(() => _selectedPeriode = p.code),
)),
const SizedBox(height: 24),
// Total estimé
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: UnionFlowColors.primaryGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.greenGlowShadow,
),
child: Row(
children: [
const Icon(Icons.calculate_outlined,
color: Colors.white70, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Estimation indicative',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12,
),
),
const SizedBox(height: 2),
const Text(
'Le montant exact est calculé par le système.',
style: TextStyle(
color: Colors.white70, fontSize: 11),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatPrix(prixSelected),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w900,
),
),
const Text(
'FCFA',
style:
TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
],
),
),
const SizedBox(height: 16),
// Note info
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: UnionFlowColors.infoPale,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: UnionFlowColors.info.withOpacity(0.2)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline,
color: UnionFlowColors.info, size: 18),
SizedBox(width: 10),
Expanded(
child: Text(
'Le type de votre organisation et le coefficient tarifaire exact sont déterminés lors de la création de votre compte. Le récapitulatif final vous montrera le montant précis.',
style: TextStyle(
fontSize: 12,
color: UnionFlowColors.info,
height: 1.4),
),
),
],
),
),
],
),
),
),
],
),
bottomNavigationBar: OnboardingBottomBar(
enabled: true,
label: 'Voir le récapitulatif',
onPressed: () {
context.read<OnboardingBloc>()
..add(OnboardingPeriodeSelected(
typePeriode: _selectedPeriode,
typeOrganisation: '',
organisationId: _organisationId,
))
..add(const OnboardingDemandeConfirmee());
},
),
);
}
String _formatPrix(double prix) {
if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
if (prix >= 1000) {
final parts = prix.toStringAsFixed(0);
if (parts.length > 3) {
return '${parts.substring(0, parts.length - 3)} ${parts.substring(parts.length - 3)}';
}
}
return prix.toStringAsFixed(0);
}
}
class _Periode {
final String code, label, duree;
final String? badge;
final int nbMois;
final double coef;
const _Periode(this.code, this.label, this.duree, this.badge, this.nbMois, this.coef);
}
class _FormulaRecap extends StatelessWidget {
final String codeFormule;
final String plage;
final double prixMensuel;
const _FormulaRecap({
required this.codeFormule,
required this.plage,
required this.prixMensuel,
});
static const _plageLabels = {
'PETITE': '1100 membres',
'MOYENNE': '101500 membres',
'GRANDE': '5012 000 membres',
'TRES_GRANDE': '2 000+ membres',
};
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: UnionFlowColors.unionGreenPale,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: UnionFlowColors.unionGreen.withOpacity(0.25)),
),
child: Row(
children: [
const Icon(Icons.check_circle_rounded,
color: UnionFlowColors.unionGreen, size: 20),
const SizedBox(width: 10),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
color: UnionFlowColors.textPrimary, fontSize: 13),
children: [
TextSpan(
text: 'Formule $codeFormule',
style: const TextStyle(fontWeight: FontWeight.w700),
),
const TextSpan(text: ' · '),
TextSpan(
text: _plageLabels[plage] ?? plage,
style: const TextStyle(
color: UnionFlowColors.textSecondary),
),
],
),
),
),
Text(
'${_formatPrix(prixMensuel)} FCFA/mois',
style: const TextStyle(
color: UnionFlowColors.unionGreen,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
],
),
);
}
String _formatPrix(double prix) {
if (prix >= 1000) {
final k = (prix / 1000).toStringAsFixed(0);
return '$k 000';
}
return prix.toStringAsFixed(0);
}
}
class _PeriodeCard extends StatelessWidget {
final _Periode periode;
final bool selected;
final double prixTotal;
final VoidCallback onTap;
const _PeriodeCard({
required this.periode,
required this.selected,
required this.prixTotal,
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: [
Icon(
selected ? Icons.check_circle_rounded : Icons.radio_button_unchecked,
color: selected ? UnionFlowColors.unionGreen : UnionFlowColors.border,
size: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
periode.label,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.textPrimary,
),
),
if (periode.badge != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: UnionFlowColors.successPale,
borderRadius: BorderRadius.circular(20),
),
child: Text(
periode.badge!,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: UnionFlowColors.success,
),
),
),
],
],
),
Text(
periode.duree,
style: const TextStyle(
fontSize: 12,
color: UnionFlowColors.textSecondary),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'~ ${_formatPrix(prixTotal)}',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
color: selected
? UnionFlowColors.unionGreen
: UnionFlowColors.textPrimary,
),
),
const Text(
'FCFA',
style: TextStyle(
fontSize: 11,
color: UnionFlowColors.textSecondary),
),
],
),
],
),
),
),
);
}
String _formatPrix(double prix) {
if (prix >= 1000000) return '${(prix / 1000000).toStringAsFixed(1)} M';
if (prix >= 1000) {
final s = prix.toStringAsFixed(0);
if (s.length > 3) {
return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}';
}
}
return prix.toStringAsFixed(0);
}
}