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

283 lines
9.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/onboarding_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import 'plan_selection_page.dart';
import 'period_selection_page.dart';
import 'subscription_summary_page.dart';
import 'payment_method_page.dart';
import 'wave_payment_page.dart';
import 'awaiting_validation_page.dart';
/// Page conteneur du workflow d'onboarding.
/// Reçoit l'état initial du backend (onboardingState) et dispatch au bon écran.
class OnboardingFlowPage extends StatelessWidget {
final String onboardingState;
final String? souscriptionId;
final String organisationId;
final String? typeOrganisation;
const OnboardingFlowPage({
super.key,
required this.onboardingState,
required this.organisationId,
this.typeOrganisation,
this.souscriptionId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<OnboardingBloc>()
..add(OnboardingStarted(
initialState: onboardingState,
existingSouscriptionId: souscriptionId,
typeOrganisation: typeOrganisation,
organisationId: organisationId.isNotEmpty ? organisationId : null,
)),
child: const _OnboardingFlowView(),
);
}
}
class _OnboardingFlowView extends StatelessWidget {
const _OnboardingFlowView();
@override
Widget build(BuildContext context) {
return BlocConsumer<OnboardingBloc, OnboardingState>(
listener: (context, state) {
// Paiement confirmé → re-check du statut (auto-activation backend)
if (state is OnboardingPaiementConfirme) {
context.read<AuthBloc>().add(const AuthStatusChecked());
}
},
builder: (context, state) {
if (state is OnboardingLoading || state is OnboardingInitial || state is OnboardingPaiementConfirme) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(
color: UnionFlowColors.unionGreen,
),
const SizedBox(height: 16),
Text(
state is OnboardingPaiementConfirme
? 'Activation de votre compte…'
: 'Chargement…',
style: const TextStyle(
color: UnionFlowColors.textSecondary,
fontSize: 15,
),
),
],
),
),
);
}
if (state is OnboardingError) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: UnionFlowColors.errorPale,
shape: BoxShape.circle,
),
child: const Icon(Icons.error_outline,
size: 40, color: UnionFlowColors.error),
),
const SizedBox(height: 20),
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(
color: UnionFlowColors.textPrimary, fontSize: 15),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.read<OnboardingBloc>().add(
const OnboardingStarted(
initialState: 'NO_SUBSCRIPTION'),
),
style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: AppColors.onPrimary,
),
child: const Text('Réessayer'),
),
],
),
),
),
);
}
if (state is OnboardingStepFormule) {
return PlanSelectionPage(formules: state.formules);
}
if (state is OnboardingStepPeriode) {
return PeriodSelectionPage(
codeFormule: state.codeFormule,
plage: state.plage,
formules: state.formules,
);
}
if (state is OnboardingStepSummary) {
return SubscriptionSummaryPage(souscription: state.souscription);
}
if (state is OnboardingStepChoixPaiement) {
return PaymentMethodPage(souscription: state.souscription);
}
if (state is OnboardingStepPaiement) {
return WavePaymentPage(
souscription: state.souscription,
waveLaunchUrl: state.waveLaunchUrl,
);
}
// É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) {
return AwaitingValidationPage(souscription: state.souscription);
}
if (state is OnboardingRejected) {
return _RejectedPage(commentaire: state.commentaire);
}
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
},
);
}
}
class _RejectedPage extends StatelessWidget {
final String? commentaire;
const _RejectedPage({this.commentaire});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: UnionFlowColors.errorPale,
shape: BoxShape.circle,
),
child: const Icon(Icons.cancel_outlined,
size: 52, color: UnionFlowColors.error),
),
const SizedBox(height: 28),
const Text(
'Demande rejetée',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: UnionFlowColors.textPrimary,
),
),
const SizedBox(height: 12),
const Text(
'Votre demande de souscription a été refusée.',
textAlign: TextAlign.center,
style: TextStyle(color: UnionFlowColors.textSecondary),
),
if (commentaire != null && commentaire!.isNotEmpty) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UnionFlowColors.errorPale,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: UnionFlowColors.error.withOpacity(0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.comment_outlined,
color: UnionFlowColors.error, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
commentaire!,
style: const TextStyle(
color: UnionFlowColors.textPrimary,
fontSize: 14,
height: 1.5),
),
),
],
),
),
],
const SizedBox(height: 36),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => context.read<OnboardingBloc>().add(
const OnboardingStarted(
initialState: 'NO_SUBSCRIPTION'),
),
style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: AppColors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: const Text('Soumettre une nouvelle demande',
style: TextStyle(fontSize: 15)),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () =>
context.read<AuthBloc>().add(const AuthLogoutRequested()),
child: const Text('Se déconnecter',
style:
TextStyle(color: UnionFlowColors.textSecondary)),
),
],
),
),
),
);
}
}