RefreshIndicator ajouté (dispatche les events BLoC appropriés) : - adhesion_detail, adhesions_page, demande_aide_detail, demandes_aide_page - event_detail, organization_detail, org_selector, org_types - user_management_detail, reports (TabBarView), logs (Dashboard tab) - profile (onglet Perso), backup (3 onglets), notifications Fixes associés : - AlwaysScrollableScrollPhysics sur tous les scroll widgets (permet pull-to-refresh même si contenu < écran) - Empty states des listes : wrappés dans SingleChildScrollView pour refresh - Dark mode adaptatif sur textes/surfaces/borders hardcodés - backup_page : bouton retour ajouté dans le header gradient - org_types : chevron/star/border adaptatifs - reports : couleurs placeholders graphique + chevrons
2002 lines
66 KiB
Dart
2002 lines
66 KiB
Dart
import 'dart:io' show Platform, Directory, File;
|
|
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../../../shared/design_system/unionflow_design_system.dart';
|
|
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
|
import '../../../../shared/widgets/core_card.dart';
|
|
import '../../../../shared/widgets/info_badge.dart';
|
|
import '../../../../shared/widgets/mini_avatar.dart';
|
|
import '../../../../core/config/environment.dart';
|
|
import '../../../../core/l10n/locale_provider.dart';
|
|
import '../../../../core/theme/theme_provider.dart';
|
|
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
|
import '../../../members/data/models/membre_complete_model.dart';
|
|
import '../../../organizations/bloc/org_switcher_bloc.dart';
|
|
import '../../../organizations/presentation/pages/org_selector_page.dart';
|
|
import '../../../settings/presentation/pages/language_settings_page.dart';
|
|
import '../../../settings/presentation/pages/privacy_settings_page.dart';
|
|
import '../../../settings/presentation/pages/feedback_page.dart';
|
|
import '../widgets/kyc_status_widget.dart';
|
|
import '../bloc/profile_bloc.dart';
|
|
|
|
/// Page Mon Profil - UnionFlow Mobile
|
|
///
|
|
/// Page complète de gestion du profil utilisateur avec informations personnelles,
|
|
/// préférences, sécurité, et paramètres avancés.
|
|
class ProfilePage extends StatefulWidget {
|
|
const ProfilePage({super.key});
|
|
|
|
@override
|
|
State<ProfilePage> createState() => _ProfilePageState();
|
|
}
|
|
|
|
class _ProfilePageState extends State<ProfilePage>
|
|
with TickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Contrôleurs pour les champs de texte
|
|
final _firstNameController = TextEditingController();
|
|
final _lastNameController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _phoneController = TextEditingController();
|
|
final _addressController = TextEditingController();
|
|
final _cityController = TextEditingController();
|
|
final _postalCodeController = TextEditingController();
|
|
final _bioController = TextEditingController();
|
|
|
|
// État du profil
|
|
bool _isEditing = false;
|
|
bool _isLoading = false;
|
|
String? _membreId;
|
|
String _selectedLanguage = 'Français';
|
|
String _selectedTheme = 'Système';
|
|
bool _biometricEnabled = false;
|
|
bool _twoFactorEnabled = false;
|
|
// Préférences notifications (persistées via SharedPreferences)
|
|
bool _notifPush = true;
|
|
bool _notifEmail = false;
|
|
bool _notifSon = true;
|
|
// Infos app (chargées via package_info_plus)
|
|
String _appVersion = '...';
|
|
String _platformInfo = '...';
|
|
String _osVersion = '...';
|
|
String _envInfo = '...';
|
|
// Tailles stockage (calculées au chargement)
|
|
String _cacheSize = '...';
|
|
String _imagesSize = '...';
|
|
String _offlineSize = '...';
|
|
// Options développeur (persistées via SharedPreferences)
|
|
bool _devMode = false;
|
|
bool _detailedLogs = false;
|
|
final List<String> _themes = ['Système', 'Clair', 'Sombre'];
|
|
|
|
static const _keyNotifPush = 'notif_push';
|
|
static const _keyNotifEmail = 'notif_email';
|
|
static const _keyNotifSon = 'notif_son';
|
|
static const _keyDevMode = 'dev_mode';
|
|
static const _keyDetailedLogs = 'detailed_logs';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 4, vsync: this);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_syncLanguageFromProvider();
|
|
_loadProfileFromAuth();
|
|
});
|
|
_loadPreferences();
|
|
_loadDeviceInfo();
|
|
_loadStorageInfo();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_firstNameController.dispose();
|
|
_lastNameController.dispose();
|
|
_emailController.dispose();
|
|
_phoneController.dispose();
|
|
_addressController.dispose();
|
|
_cityController.dispose();
|
|
_postalCodeController.dispose();
|
|
_bioController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocListener<ProfileBloc, ProfileState>(
|
|
listener: (context, state) {
|
|
if (state is ProfileLoaded) {
|
|
_populateFromMembre(state.membre);
|
|
}
|
|
if (state is ProfileUpdated) {
|
|
_populateFromMembre(state.membre);
|
|
_showSuccessSnackBar('Profil mis à jour avec succès');
|
|
setState(() {
|
|
_isEditing = false;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
if (state is ProfileError) {
|
|
setState(() => _isLoading = false);
|
|
_showErrorSnackBar(state.message);
|
|
}
|
|
if (state is ProfileUpdating) {
|
|
setState(() => _isLoading = true);
|
|
}
|
|
if (state is PasswordChanged) {
|
|
_showSuccessSnackBar('Mot de passe modifié avec succès');
|
|
}
|
|
if (state is AccountDeleted) {
|
|
_showSuccessSnackBar('Compte supprimé');
|
|
context.read<AuthBloc>().add(AuthLogoutRequested());
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
|
appBar: UFAppBar(
|
|
title: 'Mon Profil',
|
|
moduleGradient: ModuleColors.profilGradient,
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(_isEditing ? Icons.save_outlined : Icons.edit_outlined, size: 20),
|
|
onPressed: () => _isEditing ? _saveProfile() : _startEditing(),
|
|
),
|
|
],
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
isScrollable: true,
|
|
labelColor: Colors.white,
|
|
unselectedLabelColor: Colors.white70,
|
|
indicatorColor: Colors.white,
|
|
indicatorSize: TabBarIndicatorSize.label,
|
|
labelStyle: AppTypography.actionText.copyWith(fontSize: 10, fontWeight: FontWeight.bold),
|
|
tabs: const [
|
|
Tab(child: Text('PERSO')),
|
|
Tab(child: Text('PRÉFÉRENCES')),
|
|
Tab(child: Text('SÉCURITÉ')),
|
|
Tab(child: Text('AVANCÉ')),
|
|
],
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: _buildHeader(),
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildPersonalInfoTab(),
|
|
_buildPreferencesTab(),
|
|
_buildSecurityTab(),
|
|
_buildAdvancedTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Header avec données réelles du backend
|
|
Widget _buildHeader() {
|
|
return BlocBuilder<ProfileBloc, ProfileState>(
|
|
builder: (context, state) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final membre = (state is ProfileLoaded)
|
|
? state.membre
|
|
: (state is ProfileUpdated ? state.membre
|
|
: (state is ProfileUpdating ? state.membre : null));
|
|
|
|
final isLoading = state is ProfileLoading || state is ProfileInitial;
|
|
final hasError = state is ProfileError || state is ProfileNotFound;
|
|
|
|
// Badge : rôle de l'utilisateur (SUPER_ADMIN, ADMIN, etc.) + statut
|
|
String badgeText;
|
|
Color badgeColor;
|
|
if (isLoading) {
|
|
badgeText = 'CHARGEMENT...';
|
|
badgeColor = ColorTokens.textSecondary;
|
|
} else if (hasError) {
|
|
badgeText = 'ERREUR CHARGEMENT';
|
|
badgeColor = ColorTokens.error;
|
|
} else if (membre != null) {
|
|
// Priorité au rôle s'il est disponible
|
|
if (membre.role != null && membre.role!.isNotEmpty) {
|
|
badgeText = membre.role!.replaceAll('_', ' ');
|
|
badgeColor = ModuleColors.profil;
|
|
} else {
|
|
// Fallback sur le statut
|
|
switch (membre.statut) {
|
|
case StatutMembre.actif:
|
|
badgeText = membre.cotisationAJour ? 'MEMBRE ACTIF' : 'COTISATION EN RETARD';
|
|
badgeColor = membre.cotisationAJour ? ColorTokens.success : ColorTokens.warning;
|
|
break;
|
|
case StatutMembre.inactif:
|
|
badgeText = 'INACTIF';
|
|
badgeColor = ColorTokens.textSecondary;
|
|
break;
|
|
case StatutMembre.suspendu:
|
|
badgeText = 'SUSPENDU';
|
|
badgeColor = ColorTokens.error;
|
|
break;
|
|
case StatutMembre.enAttente:
|
|
badgeText = 'EN ATTENTE';
|
|
badgeColor = ColorTokens.warning;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
badgeText = 'CHARGEMENT...';
|
|
badgeColor = ColorTokens.textSecondary;
|
|
}
|
|
|
|
// Ancienneté réelle
|
|
String depuisValue = '—';
|
|
if (membre?.dateAdhesion != null) {
|
|
final days = membre!.ancienneteJours ?? 0;
|
|
if (days < 30) {
|
|
depuisValue = '$days J';
|
|
} else if (days < 365) {
|
|
depuisValue = '${(days / 30).floor()} MOIS';
|
|
} else {
|
|
depuisValue = '${(days / 365).floor()} ANS';
|
|
}
|
|
}
|
|
|
|
// Événements réels
|
|
final eventsValue = membre != null
|
|
? '${membre.nombreEvenementsParticipes}'
|
|
: '—';
|
|
|
|
// Organisation réelle
|
|
final orgValue = membre?.organisationNom != null ? '1' : '—';
|
|
|
|
return CoreCard(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: _pickProfileImage,
|
|
child: MiniAvatar(
|
|
size: 64,
|
|
fallbackText: membre != null
|
|
? membre.initiales
|
|
: (_firstNameController.text.isNotEmpty
|
|
? _firstNameController.text[0].toUpperCase()
|
|
: '?'),
|
|
imageUrl: membre?.photo,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${_firstNameController.text} ${_lastNameController.text}'.trim().toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 14, fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
_emailController.text.toLowerCase(),
|
|
style: AppTypography.subtitleSmall.copyWith(fontSize: 11, color: scheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (isLoading)
|
|
const SizedBox(
|
|
height: 16,
|
|
width: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
else
|
|
InfoBadge(text: badgeText, backgroundColor: badgeColor),
|
|
if (hasError)
|
|
GestureDetector(
|
|
onTap: _loadProfileFromAuth,
|
|
child: Text(
|
|
'Réessayer',
|
|
style: AppTypography.subtitleSmall.copyWith(
|
|
color: ModuleColors.profil,
|
|
fontSize: 10,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_buildStatItem('DEPUIS', depuisValue),
|
|
_buildStatItem('EVENTS', eventsValue),
|
|
_buildStatItem('ORG', orgValue),
|
|
],
|
|
),
|
|
// Sélecteur d'organisation (multi-org)
|
|
BlocBuilder<OrgSwitcherBloc, OrgSwitcherState>(
|
|
builder: (context, orgState) {
|
|
if (orgState is! OrgSwitcherLoaded ||
|
|
orgState.organisations.length <= 1) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Center(
|
|
child: OrgSwitcherBadge(
|
|
activeOrg: orgState.active,
|
|
onTap: () async {
|
|
final selected = await showOrgSelector(context);
|
|
if (selected != null && context.mounted) {
|
|
context
|
|
.read<OrgSwitcherBloc>()
|
|
.add(OrgSwitcherSelectRequested(selected));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildStatItem(String label, String value) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Column(
|
|
children: [
|
|
Text(value, style: AppTypography.headerSmall.copyWith(fontSize: 14)),
|
|
Text(label, style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold, color: scheme.onSurfaceVariant)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Carte de statistique
|
|
Widget _buildStatCard(String label, String value, IconData icon) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// _buildTabBar() supprimé : migré dans UFAppBar.bottom (pattern Adhésions)
|
|
|
|
/// Onglet informations personnelles
|
|
Widget _buildPersonalInfoTab() {
|
|
return RefreshIndicator(
|
|
color: ModuleColors.profil,
|
|
onRefresh: () async =>
|
|
context.read<ProfileBloc>().add(const LoadMe()),
|
|
child: SingleChildScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
|
|
// Section informations de base
|
|
_buildInfoSection(
|
|
'Informations personnelles',
|
|
'Vos données personnelles et de contact',
|
|
Icons.person,
|
|
[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildTextField(
|
|
controller: _firstNameController,
|
|
label: 'Prénom',
|
|
icon: Icons.person_outline,
|
|
enabled: _isEditing,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildTextField(
|
|
controller: _lastNameController,
|
|
label: 'Nom',
|
|
icon: Icons.person_outline,
|
|
enabled: _isEditing,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
_buildTextField(
|
|
controller: _emailController,
|
|
label: 'Email',
|
|
icon: Icons.email_outlined,
|
|
enabled: _isEditing,
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
_buildTextField(
|
|
controller: _phoneController,
|
|
label: 'Téléphone',
|
|
icon: Icons.phone_outlined,
|
|
enabled: _isEditing,
|
|
keyboardType: TextInputType.phone,
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Section adresse
|
|
_buildInfoSection(
|
|
'Adresse',
|
|
'Votre adresse de résidence',
|
|
Icons.location_on,
|
|
[
|
|
_buildTextField(
|
|
controller: _addressController,
|
|
label: 'Adresse',
|
|
icon: Icons.home_outlined,
|
|
enabled: _isEditing,
|
|
maxLines: 2,
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: _buildTextField(
|
|
controller: _cityController,
|
|
label: 'Ville',
|
|
icon: Icons.location_city_outlined,
|
|
enabled: _isEditing,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildTextField(
|
|
controller: _postalCodeController,
|
|
label: 'Code postal',
|
|
icon: Icons.markunread_mailbox_outlined,
|
|
enabled: _isEditing,
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Section biographie
|
|
_buildInfoSection(
|
|
'À propos de moi',
|
|
'Partagez quelques mots sur vous',
|
|
Icons.info,
|
|
[
|
|
_buildTextField(
|
|
controller: _bioController,
|
|
label: 'Biographie',
|
|
icon: Icons.edit_outlined,
|
|
enabled: _isEditing,
|
|
maxLines: 4,
|
|
hintText: 'Parlez-nous de vous, vos intérêts, votre parcours...',
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Boutons d'action
|
|
_buildActionButtons(),
|
|
|
|
const SizedBox(height: 80),
|
|
],
|
|
), // Column
|
|
), // SingleChildScrollView
|
|
); // RefreshIndicator
|
|
}
|
|
|
|
/// Section d'informations
|
|
Widget _buildInfoSection(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
List<Widget> children,
|
|
) {
|
|
return CoreCard(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, color: ModuleColors.profil, size: 16),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
...children.map((child) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: child,
|
|
)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Champ de texte personnalisé
|
|
Widget _buildTextField({
|
|
required TextEditingController controller,
|
|
required String label,
|
|
required IconData icon,
|
|
bool enabled = true,
|
|
TextInputType? keyboardType,
|
|
int maxLines = 1,
|
|
String? hintText,
|
|
}) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return TextFormField(
|
|
controller: controller,
|
|
enabled: enabled,
|
|
keyboardType: keyboardType,
|
|
maxLines: maxLines,
|
|
style: AppTypography.bodyTextSmall.copyWith(
|
|
fontSize: 12,
|
|
),
|
|
decoration: InputDecoration(
|
|
labelText: label.toUpperCase(),
|
|
labelStyle: AppTypography.subtitleSmall.copyWith(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.bold,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
hintText: hintText,
|
|
prefixIcon: Icon(icon, color: enabled ? ModuleColors.profil : ColorTokens.textSecondary, size: 16),
|
|
filled: true,
|
|
fillColor: scheme.surface,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(4),
|
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(4),
|
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(4),
|
|
borderSide: BorderSide(color: ModuleColors.profil, width: 1),
|
|
),
|
|
disabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(4),
|
|
borderSide: BorderSide(color: scheme.outlineVariant, width: 0.5),
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (label == 'Email' && value != null && value.isNotEmpty) {
|
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
|
return 'Email invalide';
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Boutons d'action
|
|
Widget _buildActionButtons() {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.shadow,
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (_isEditing) ...[
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _isLoading ? null : _cancelEditing,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
foregroundColor: AppColors.textSecondary,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
icon: const Icon(Icons.cancel, size: 18),
|
|
label: const Text('Annuler'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _isLoading ? null : _saveProfile,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: ModuleColors.profil,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
icon: _isLoading
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: const Icon(Icons.save, size: 18),
|
|
label: Text(_isLoading ? 'Sauvegarde...' : 'Sauvegarder'),
|
|
),
|
|
),
|
|
] else ...[
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _startEditing,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: ModuleColors.profil,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
icon: const Icon(Icons.edit, size: 18),
|
|
label: const Text('Modifier le profil'),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Onglet préférences
|
|
Widget _buildPreferencesTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
|
|
// Langue et région
|
|
_buildPreferenceSection(
|
|
'Langue et région',
|
|
'Personnaliser l\'affichage',
|
|
Icons.language,
|
|
[
|
|
Builder(
|
|
builder: (ctx) {
|
|
final scheme = Theme.of(ctx).colorScheme;
|
|
return InkWell(
|
|
onTap: () async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (context) => const LanguageSettingsPage()),
|
|
);
|
|
if (mounted) {
|
|
final lp = context.read<LocaleProvider>();
|
|
setState(() => _selectedLanguage = lp.currentLanguageName);
|
|
}
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.language, color: scheme.onSurfaceVariant, size: 22),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Langue', style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
|
|
Text('Actuellement : $_selectedLanguage', style: AppTypography.subtitleSmall.copyWith(color: scheme.onSurfaceVariant)),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant, size: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
_buildDropdownPreference(
|
|
'Thème',
|
|
'Apparence de l\'application',
|
|
_selectedTheme,
|
|
_themes,
|
|
(value) {
|
|
if (value == null) return;
|
|
setState(() => _selectedTheme = value);
|
|
final mode = switch (value) {
|
|
'Clair' => ThemeMode.light,
|
|
'Sombre' => ThemeMode.dark,
|
|
_ => ThemeMode.system,
|
|
};
|
|
context.read<ThemeProvider>().setMode(mode);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Notifications
|
|
_buildPreferenceSection(
|
|
'Notifications',
|
|
'Gérer vos alertes',
|
|
Icons.notifications,
|
|
[
|
|
_buildSwitchPreference(
|
|
'Notifications push',
|
|
'Recevoir des notifications sur cet appareil',
|
|
_notifPush,
|
|
(value) async {
|
|
setState(() => _notifPush = value);
|
|
await _savePreference(_keyNotifPush, value);
|
|
},
|
|
),
|
|
_buildSwitchPreference(
|
|
'Notifications email',
|
|
'Recevoir des emails de notification',
|
|
_notifEmail,
|
|
(value) async {
|
|
setState(() => _notifEmail = value);
|
|
await _savePreference(_keyNotifEmail, value);
|
|
},
|
|
),
|
|
_buildSwitchPreference(
|
|
'Sons et vibrations',
|
|
'Alertes sonores et vibrations',
|
|
_notifSon,
|
|
(value) async {
|
|
setState(() => _notifSon = value);
|
|
await _savePreference(_keyNotifSon, value);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Confidentialité
|
|
_buildPreferenceSection(
|
|
'Confidentialité',
|
|
'Contrôler vos données',
|
|
Icons.privacy_tip,
|
|
[
|
|
Builder(
|
|
builder: (ctx) {
|
|
final scheme = Theme.of(ctx).colorScheme;
|
|
return InkWell(
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (context) => const PrivacySettingsPage()),
|
|
);
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.privacy_tip, color: scheme.onSurfaceVariant, size: 22),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Gérer la confidentialité', style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
|
|
Text('Visibilité, partage de données, suppression de compte', style: AppTypography.subtitleSmall.copyWith(color: scheme.onSurfaceVariant)),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant, size: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Onglet sécurité
|
|
Widget _buildSecurityTab() {
|
|
return BlocBuilder<ProfileBloc, ProfileState>(
|
|
builder: (context, state) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
|
|
// KYC/LCB-FT Status (si membre chargé)
|
|
if (state is ProfileLoaded) ...[
|
|
KycStatusWidget(
|
|
niveauVigilance: state.membre.niveauVigilanceKyc,
|
|
statutKyc: state.membre.statutKyc,
|
|
dateVerification: state.membre.dateVerificationIdentite,
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Authentification
|
|
_buildSecuritySection(
|
|
'Authentification',
|
|
'Sécuriser votre compte',
|
|
Icons.security,
|
|
[
|
|
_buildSecurityItem(
|
|
'Changer le mot de passe',
|
|
'Dernière modification il y a 3 mois',
|
|
Icons.lock_outline,
|
|
() => _showChangePasswordDialog(),
|
|
),
|
|
_buildSwitchPreference(
|
|
'Authentification biométrique',
|
|
'Utiliser l\'empreinte digitale ou Face ID',
|
|
_biometricEnabled,
|
|
(value) {
|
|
setState(() => _biometricEnabled = value);
|
|
_showSuccessSnackBar('Authentification biométrique ${value ? 'activée' : 'désactivée'}');
|
|
},
|
|
),
|
|
_buildSwitchPreference(
|
|
'Authentification à deux facteurs',
|
|
'Sécurité renforcée avec SMS ou app',
|
|
_twoFactorEnabled,
|
|
(value) {
|
|
setState(() => _twoFactorEnabled = value);
|
|
if (value) {
|
|
_showTwoFactorSetupDialog();
|
|
} else {
|
|
_showSuccessSnackBar('Authentification à deux facteurs désactivée');
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Sessions actives
|
|
_buildSecuritySection(
|
|
'Session active',
|
|
'Cet appareil',
|
|
Icons.devices,
|
|
[
|
|
_buildSessionItem(
|
|
'Cet appareil',
|
|
'${kIsWeb ? "Web" : Platform.isAndroid ? "Android" : Platform.isIOS ? "iOS" : "Bureau"} • Maintenant',
|
|
kIsWeb ? Icons.web : Platform.isAndroid ? Icons.smartphone : Platform.isIOS ? Icons.phone_iphone : Icons.computer,
|
|
true,
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Actions de sécurité
|
|
_buildSecuritySection(
|
|
'Actions de sécurité',
|
|
'Gérer votre compte',
|
|
Icons.warning,
|
|
[
|
|
_buildActionItem(
|
|
'Télécharger mes données',
|
|
'Exporter toutes vos données personnelles',
|
|
Icons.download,
|
|
ModuleColors.profil,
|
|
() => _exportUserData(),
|
|
),
|
|
_buildActionItem(
|
|
'Déconnecter tous les appareils',
|
|
'Fermer toutes les sessions actives',
|
|
Icons.logout,
|
|
ColorTokens.warning,
|
|
() => _logoutAllDevices(),
|
|
),
|
|
_buildActionItem(
|
|
'Supprimer mon compte',
|
|
'Action irréversible - toutes les données seront perdues',
|
|
Icons.delete_forever,
|
|
ColorTokens.error,
|
|
() => _showDeleteAccountDialog(),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Onglet avancé
|
|
Widget _buildAdvancedTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
|
|
// Données et stockage
|
|
_buildAdvancedSection(
|
|
'Données et stockage',
|
|
'Gérer l\'utilisation des données',
|
|
Icons.storage,
|
|
kIsWeb
|
|
? [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
'Gestion du stockage non disponible sur navigateur web.',
|
|
style: AppTypography.subtitleSmall.copyWith(fontSize: 11),
|
|
),
|
|
),
|
|
]
|
|
: [
|
|
_buildStorageItem('Cache de l\'application', _cacheSize, () => _clearCache()),
|
|
_buildStorageItem('Images téléchargées', _imagesSize, () => _clearImages()),
|
|
_buildStorageItem('Données hors ligne', _offlineSize, () => _clearOfflineData()),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Développeur
|
|
_buildAdvancedSection(
|
|
'Options développeur',
|
|
'Paramètres avancés',
|
|
Icons.code,
|
|
[
|
|
_buildSwitchPreference(
|
|
'Mode développeur',
|
|
'Afficher les options de débogage',
|
|
_devMode,
|
|
(value) async {
|
|
setState(() => _devMode = value);
|
|
await _savePreference(_keyDevMode, value);
|
|
_showSuccessSnackBar('Mode développeur ${value ? 'activé' : 'désactivé'}');
|
|
},
|
|
),
|
|
_buildSwitchPreference(
|
|
'Logs détaillés',
|
|
'Logs réseau et erreurs — actif au prochain démarrage',
|
|
_detailedLogs,
|
|
(value) async {
|
|
setState(() => _detailedLogs = value);
|
|
await _savePreference(_keyDetailedLogs, value);
|
|
_showSuccessSnackBar('Logs détaillés ${value ? 'activés' : 'désactivés'} (prochain démarrage)');
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Informations système
|
|
_buildAdvancedSection(
|
|
'Informations système',
|
|
'Détails techniques',
|
|
Icons.info,
|
|
[
|
|
_buildInfoItem('Version de l\'app', _appVersion),
|
|
_buildInfoItem('Plateforme', _platformInfo),
|
|
_buildInfoItem('Système', _osVersion),
|
|
_buildInfoItem('Environnement', _envInfo),
|
|
if (_devMode) _buildInfoItem('API URL', AppConfig.apiBaseUrl),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Feedback / Commentaires
|
|
_buildAdvancedSection(
|
|
'Commentaires',
|
|
'Aidez-nous à améliorer l\'application',
|
|
Icons.feedback,
|
|
[
|
|
Builder(
|
|
builder: (ctx) {
|
|
final scheme = Theme.of(ctx).colorScheme;
|
|
return InkWell(
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (context) => const FeedbackPage()),
|
|
);
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.edit_note, color: scheme.onSurfaceVariant, size: 22),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Envoyer des commentaires', style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
|
|
Text('Suggestions, bugs ou idées d\'amélioration', style: AppTypography.subtitleSmall.copyWith(color: scheme.onSurfaceVariant)),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant, size: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Feedback dialog déplacé dans FeedbackPage
|
|
|
|
// ==================== MÉTHODES DE CONSTRUCTION DES COMPOSANTS ====================
|
|
|
|
/// Section de préférence
|
|
Widget _buildPreferenceSection(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
List<Widget> children,
|
|
) {
|
|
return CoreCard(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, color: ModuleColors.profil, size: 16),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
...children.map((child) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: child,
|
|
)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Section de sécurité
|
|
Widget _buildSecuritySection(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
List<Widget> children,
|
|
) {
|
|
return _buildPreferenceSection(title, subtitle, icon, children);
|
|
}
|
|
|
|
/// Section avancée
|
|
Widget _buildAdvancedSection(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
List<Widget> children,
|
|
) {
|
|
return _buildPreferenceSection(title, subtitle, icon, children);
|
|
}
|
|
|
|
/// Préférence avec dropdown
|
|
Widget _buildDropdownPreference(
|
|
String title,
|
|
String subtitle,
|
|
String value,
|
|
List<String> options,
|
|
Function(String?) onChanged,
|
|
) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.subtitleSmall.copyWith(fontSize: 9, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: scheme.outlineVariant, width: 0.5),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: value,
|
|
isExpanded: true,
|
|
onChanged: onChanged,
|
|
dropdownColor: scheme.surface,
|
|
icon: Icon(Icons.arrow_drop_down, color: ModuleColors.profil, size: 18),
|
|
style: AppTypography.bodyTextSmall.copyWith(
|
|
fontSize: 12,
|
|
color: scheme.onSurface,
|
|
),
|
|
items: options.map((option) {
|
|
return DropdownMenuItem<String>(
|
|
value: option,
|
|
child: Text(
|
|
option,
|
|
style: AppTypography.bodyTextSmall.copyWith(
|
|
fontSize: 12,
|
|
color: scheme.onSurface,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Préférence avec switch
|
|
Widget _buildSwitchPreference(
|
|
String title,
|
|
String subtitle,
|
|
bool value,
|
|
Function(bool) onChanged,
|
|
) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.subtitleSmall.copyWith(fontSize: 10, fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: AppTypography.bodyTextSmall.copyWith(
|
|
fontSize: 10,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Transform.scale(
|
|
scale: 0.8,
|
|
child: Switch(
|
|
value: value,
|
|
onChanged: onChanged,
|
|
activeColor: ModuleColors.profil,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Élément de sécurité
|
|
Widget _buildSecurityItem(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
VoidCallback onTap,
|
|
) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: scheme.outlineVariant, width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: ModuleColors.profil, size: 16),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: scheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.arrow_forward_ios, color: scheme.onSurfaceVariant, size: 12),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Élément de session
|
|
Widget _buildSessionItem(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
bool isCurrentDevice,
|
|
) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isCurrentDevice
|
|
? ModuleColors.profil.withOpacity(0.05)
|
|
: scheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(
|
|
color: isCurrentDevice
|
|
? ModuleColors.profil.withOpacity(0.3)
|
|
: scheme.outlineVariant,
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: isCurrentDevice ? ModuleColors.profil : scheme.onSurfaceVariant,
|
|
size: 16,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
if (isCurrentDevice) ...[
|
|
const SizedBox(width: 8),
|
|
InfoBadge(text: 'ACTUEL', backgroundColor: ModuleColors.profil),
|
|
],
|
|
],
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: scheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Élément d'action
|
|
Widget _buildActionItem(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
Color color,
|
|
VoidCallback onTap,
|
|
) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: color.withOpacity(0.1), width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: color, size: 16),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 11, fontWeight: FontWeight.bold, color: color),
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: AppTypography.bodyTextSmall.copyWith(
|
|
fontSize: 10,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.arrow_forward_ios, color: color.withOpacity(0.5), size: 12),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Élément de stockage
|
|
Widget _buildStorageItem(String title, String size, VoidCallback onTap) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: scheme.outlineVariant, width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.folder_outlined, color: ModuleColors.profil, size: 16),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Text(
|
|
size,
|
|
style: AppTypography.subtitleSmall.copyWith(fontSize: 10, color: scheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(Icons.clear, color: ColorTokens.error, size: 14),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Élément d'information
|
|
Widget _buildInfoItem(String title, String value) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: scheme.outlineVariant, width: 0.5),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title.toUpperCase(),
|
|
style: AppTypography.subtitleSmall.copyWith(fontSize: 9, fontWeight: FontWeight.bold, color: scheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: AppTypography.bodyTextSmall.copyWith(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==================== MÉTHODES D'ACTION ====================
|
|
|
|
/// Synchronise la langue et le thème affichés depuis leurs providers
|
|
void _syncLanguageFromProvider() {
|
|
if (!mounted) return;
|
|
final lp = context.read<LocaleProvider>();
|
|
final tp = context.read<ThemeProvider>();
|
|
setState(() {
|
|
_selectedLanguage = lp.currentLanguageName;
|
|
_selectedTheme = switch (tp.mode) {
|
|
ThemeMode.light => 'Clair',
|
|
ThemeMode.dark => 'Sombre',
|
|
_ => 'Système',
|
|
};
|
|
});
|
|
}
|
|
|
|
// Confidentialité gérée dans PrivacySettingsPage
|
|
|
|
/// Charger le profil utilisateur
|
|
/// Charge le profil depuis l'AuthBloc et déclenche la récupération backend
|
|
void _loadProfileFromAuth() {
|
|
final authState = context.read<AuthBloc>().state;
|
|
if (authState is AuthAuthenticated) {
|
|
// Pré-remplir avec les données Keycloak disponibles immédiatement
|
|
setState(() {
|
|
_firstNameController.text = authState.user.firstName;
|
|
_lastNameController.text = authState.user.lastName;
|
|
_emailController.text = authState.user.email;
|
|
if (authState.user.phone != null) {
|
|
_phoneController.text = authState.user.phone!;
|
|
}
|
|
});
|
|
// Charger le profil complet depuis le backend
|
|
context.read<ProfileBloc>().add(const LoadMe());
|
|
}
|
|
}
|
|
|
|
/// Peuple les champs depuis un membre récupéré du backend
|
|
void _populateFromMembre(dynamic membre) {
|
|
setState(() {
|
|
_membreId = membre.id?.toString();
|
|
_firstNameController.text = membre.prenom ?? '';
|
|
_lastNameController.text = membre.nom ?? '';
|
|
_emailController.text = membre.email ?? '';
|
|
_phoneController.text = membre.telephone ?? '';
|
|
_addressController.text = membre.adresse ?? '';
|
|
_cityController.text = membre.ville ?? '';
|
|
_postalCodeController.text = membre.codePostal ?? '';
|
|
_bioController.text = membre.notes ?? '';
|
|
});
|
|
}
|
|
|
|
void _loadUserProfile() {
|
|
_loadProfileFromAuth();
|
|
}
|
|
|
|
/// Commencer l'édition
|
|
void _startEditing() {
|
|
setState(() {
|
|
_isEditing = true;
|
|
});
|
|
}
|
|
|
|
/// Annuler l'édition
|
|
void _cancelEditing() {
|
|
setState(() {
|
|
_isEditing = false;
|
|
});
|
|
_loadUserProfile(); // Recharger les données originales
|
|
}
|
|
|
|
/// Sauvegarder le profil
|
|
Future<void> _saveProfile() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
if (_membreId == null) {
|
|
_showErrorSnackBar('Profil non chargé. Impossible de sauvegarder.');
|
|
return;
|
|
}
|
|
|
|
final blocState = context.read<ProfileBloc>().state;
|
|
if (blocState is! ProfileLoaded) return;
|
|
|
|
final membreMisAJour = blocState.membre.copyWith(
|
|
prenom: _firstNameController.text.trim(),
|
|
nom: _lastNameController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
telephone: _phoneController.text.trim(),
|
|
adresse: _addressController.text.trim(),
|
|
ville: _cityController.text.trim(),
|
|
codePostal: _postalCodeController.text.trim(),
|
|
notes: _bioController.text.trim(),
|
|
);
|
|
|
|
context.read<ProfileBloc>().add(
|
|
UpdateMyProfile(membreId: _membreId!, membre: membreMisAJour),
|
|
);
|
|
}
|
|
|
|
/// Choisir une image de profil
|
|
Future<void> _pickProfileImage() async {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Changer la photo de profil'),
|
|
content: const Text('Cette fonctionnalité sera bientôt disponible !'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Charger les préférences depuis SharedPreferences
|
|
Future<void> _loadPreferences() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (mounted) {
|
|
setState(() {
|
|
_notifPush = prefs.getBool(_keyNotifPush) ?? true;
|
|
_notifEmail = prefs.getBool(_keyNotifEmail) ?? false;
|
|
_notifSon = prefs.getBool(_keyNotifSon) ?? true;
|
|
_devMode = prefs.getBool(_keyDevMode) ?? AppConfig.enableDebugMode;
|
|
_detailedLogs = prefs.getBool(_keyDetailedLogs) ?? AppConfig.enableLogging;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Persiste une préférence booléenne
|
|
Future<void> _savePreference(String key, bool value) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(key, value);
|
|
}
|
|
|
|
/// Charge les informations réelles de l'application
|
|
Future<void> _loadDeviceInfo() async {
|
|
final info = await PackageInfo.fromPlatform();
|
|
if (mounted) {
|
|
setState(() {
|
|
_appVersion = '${info.version} (build ${info.buildNumber})';
|
|
final os = kIsWeb
|
|
? 'Web'
|
|
: Platform.isAndroid
|
|
? 'Android'
|
|
: Platform.isIOS
|
|
? 'iOS'
|
|
: Platform.operatingSystem;
|
|
_platformInfo = '$os · ${info.appName}';
|
|
_osVersion = kIsWeb
|
|
? 'Navigateur web'
|
|
: Platform.operatingSystemVersion;
|
|
_envInfo = AppConfig.environment.name.toUpperCase();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Dialogue de changement de mot de passe (branché sur le backend Keycloak)
|
|
void _showChangePasswordDialog() {
|
|
final oldPassCtrl = TextEditingController();
|
|
final newPassCtrl = TextEditingController();
|
|
final confirmPassCtrl = TextEditingController();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Changer le mot de passe'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: oldPassCtrl,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Mot de passe actuel',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: newPassCtrl,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nouveau mot de passe',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: confirmPassCtrl,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Confirmer le nouveau mot de passe',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
oldPassCtrl.dispose();
|
|
newPassCtrl.dispose();
|
|
confirmPassCtrl.dispose();
|
|
},
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final oldPass = oldPassCtrl.text.trim();
|
|
final newPass = newPassCtrl.text.trim();
|
|
final confirmPass = confirmPassCtrl.text.trim();
|
|
if (oldPass.isEmpty || newPass.isEmpty || confirmPass.isEmpty) {
|
|
_showErrorSnackBar('Veuillez remplir tous les champs');
|
|
return;
|
|
}
|
|
if (newPass != confirmPass) {
|
|
_showErrorSnackBar('Les mots de passe ne correspondent pas');
|
|
return;
|
|
}
|
|
if (newPass.length < 8) {
|
|
_showErrorSnackBar('Le mot de passe doit contenir au moins 8 caractères');
|
|
return;
|
|
}
|
|
if (_membreId == null) {
|
|
_showErrorSnackBar('Profil non chargé');
|
|
return;
|
|
}
|
|
Navigator.of(ctx).pop();
|
|
context.read<ProfileBloc>().add(ChangePassword(
|
|
membreId: _membreId!,
|
|
oldPassword: oldPass,
|
|
newPassword: newPass,
|
|
));
|
|
oldPassCtrl.dispose();
|
|
newPassCtrl.dispose();
|
|
confirmPassCtrl.dispose();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: ModuleColors.profil,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Modifier'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 2FA : non disponible (Keycloak géré côté admin)
|
|
void _showTwoFactorSetupDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Authentification à deux facteurs'),
|
|
content: const Text(
|
|
'La configuration 2FA est gérée par votre administrateur Keycloak. '
|
|
'Contactez votre organisation pour activer cette fonctionnalité.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
setState(() => _twoFactorEnabled = false);
|
|
},
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Export RGPD : non disponible (pas d'endpoint backend)
|
|
void _exportUserData() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Télécharger mes données'),
|
|
content: const Text(
|
|
'Cette fonctionnalité sera disponible prochainement. '
|
|
'Pour demander l\'export de vos données, contactez l\'administrateur de votre organisation.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Déconnexion tous appareils : non disponible (pas d'endpoint backend)
|
|
void _logoutAllDevices() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Déconnecter tous les appareils'),
|
|
content: const Text(
|
|
'Cette fonctionnalité sera disponible prochainement. '
|
|
'Vous pouvez révoquer vos sessions depuis l\'interface Keycloak de votre organisation.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Suppression de compte (branché sur le backend)
|
|
void _showDeleteAccountDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Supprimer mon compte'),
|
|
content: const Text(
|
|
'ATTENTION : Cette action est irréversible !\n\n'
|
|
'Toutes vos données seront définitivement supprimées :\n'
|
|
'• Profil et informations personnelles\n'
|
|
'• Historique des événements\n'
|
|
'• Participations aux organisations\n'
|
|
'• Tous les paramètres et préférences',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
_showFinalDeleteConfirmation();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: ColorTokens.error,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Continuer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Confirmation finale — déclenche la suppression réelle sur le backend
|
|
void _showFinalDeleteConfirmation() {
|
|
final confirmCtrl = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Confirmation finale'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Tapez "SUPPRIMER" pour confirmer :'),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: confirmCtrl,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
hintText: 'SUPPRIMER',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
confirmCtrl.dispose();
|
|
},
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
if (confirmCtrl.text.trim() != 'SUPPRIMER') {
|
|
_showErrorSnackBar('Tapez exactement "SUPPRIMER" pour confirmer');
|
|
return;
|
|
}
|
|
if (_membreId == null) {
|
|
_showErrorSnackBar('Profil non chargé');
|
|
return;
|
|
}
|
|
Navigator.of(ctx).pop();
|
|
context.read<ProfileBloc>().add(DeleteAccount(_membreId!));
|
|
confirmCtrl.dispose();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: ColorTokens.error,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('SUPPRIMER DÉFINITIVEMENT'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Vide le cache temporaire de l'application
|
|
/// Calcule la taille d'un répertoire (récursif) en format lisible
|
|
Future<String> _dirSize(Directory dir) async {
|
|
if (!dir.existsSync()) return '0 B';
|
|
int bytes = 0;
|
|
try {
|
|
await for (final entity in dir.list(recursive: true)) {
|
|
if (entity is File) bytes += await entity.length();
|
|
}
|
|
} catch (_) {}
|
|
if (bytes == 0) return '0 B';
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
/// Charge les tailles des répertoires de stockage
|
|
Future<void> _loadStorageInfo() async {
|
|
if (kIsWeb) return;
|
|
try {
|
|
final tmp = await getTemporaryDirectory();
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final support = await getApplicationSupportDirectory();
|
|
final cacheSize = await _dirSize(tmp);
|
|
final imagesSize = await _dirSize(Directory('${docs.path}/images'));
|
|
final offlineSize = await _dirSize(Directory('${support.path}/offline'));
|
|
if (mounted) {
|
|
setState(() {
|
|
_cacheSize = cacheSize;
|
|
_imagesSize = imagesSize;
|
|
_offlineSize = offlineSize;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_cacheSize = 'N/A';
|
|
_imagesSize = 'N/A';
|
|
_offlineSize = 'N/A';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _clearCache() async {
|
|
if (kIsWeb) return;
|
|
try {
|
|
final dir = await getTemporaryDirectory();
|
|
if (dir.existsSync()) {
|
|
dir.listSync().forEach((e) {
|
|
try { e.deleteSync(recursive: true); } catch (_) {}
|
|
});
|
|
}
|
|
_showSuccessSnackBar('Cache vidé avec succès');
|
|
} catch (_) {
|
|
_showErrorSnackBar('Erreur lors du vidage du cache');
|
|
} finally {
|
|
_loadStorageInfo();
|
|
}
|
|
}
|
|
|
|
/// Vide le répertoire des images téléchargées
|
|
Future<void> _clearImages() async {
|
|
if (kIsWeb) return;
|
|
try {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final imgDir = Directory('${dir.path}/images');
|
|
if (imgDir.existsSync()) {
|
|
imgDir.listSync().forEach((e) {
|
|
try { e.deleteSync(recursive: true); } catch (_) {}
|
|
});
|
|
}
|
|
_showSuccessSnackBar('Images supprimées');
|
|
} catch (_) {
|
|
_showErrorSnackBar('Erreur lors de la suppression des images');
|
|
} finally {
|
|
_loadStorageInfo();
|
|
}
|
|
}
|
|
|
|
/// Vide les données hors ligne
|
|
Future<void> _clearOfflineData() async {
|
|
if (kIsWeb) return;
|
|
try {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final offlineDir = Directory('${dir.path}/offline');
|
|
if (offlineDir.existsSync()) {
|
|
offlineDir.listSync().forEach((e) {
|
|
try { e.deleteSync(recursive: true); } catch (_) {}
|
|
});
|
|
}
|
|
_showSuccessSnackBar('Données hors ligne supprimées');
|
|
} catch (_) {
|
|
_showErrorSnackBar('Erreur lors de la suppression des données hors ligne');
|
|
} finally {
|
|
_loadStorageInfo();
|
|
}
|
|
}
|
|
|
|
/// Afficher un message de succès
|
|
void _showSuccessSnackBar(String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
message.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
backgroundColor: ColorTokens.success,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showErrorSnackBar(String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
message.toUpperCase(),
|
|
style: AppTypography.actionText.copyWith(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
backgroundColor: ColorTokens.error,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
|
),
|
|
);
|
|
}
|
|
}
|