- API publique POST /auth/forgot-password et /auth/reset-password (jeton password_reset_*, anti-énumération, e-mail P'titsPas) - BDD: colonnes password_reset_token / password_reset_expires + index - Front: écrans /forgot-password, /reset-password, lien login, mutualisation formulaires par token - Template e-mail + alignement couleur; tests unitaires (auth, mail) Merge depuis develop: branche feature/127. Made-with: Cursor
842 lines
34 KiB
Dart
842 lines
34 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:p_tits_pas/services/bug_report_service.dart';
|
||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||
import '../../widgets/image_button.dart';
|
||
import '../../widgets/custom_app_text_field.dart';
|
||
import '../../services/auth_service.dart';
|
||
import '../../widgets/auth/change_password_dialog.dart';
|
||
|
||
class LoginScreen extends StatefulWidget {
|
||
const LoginScreen({super.key});
|
||
|
||
@override
|
||
State<LoginScreen> createState() => _LoginPageState();
|
||
}
|
||
|
||
class _LoginPageState extends State<LoginScreen> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
final _emailController = TextEditingController();
|
||
final _passwordController = TextEditingController();
|
||
final GlobalKey<FormFieldState<String>> _emailFormKey =
|
||
GlobalKey<FormFieldState<String>>();
|
||
late final FocusNode _emailFocus;
|
||
late final FocusNode _passwordFocus;
|
||
|
||
bool _isLoading = false;
|
||
String? _errorMessage;
|
||
|
||
static const double _mobileBreakpoint = 900.0;
|
||
|
||
/// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web,
|
||
/// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS
|
||
/// ou des comportements bizarres pendant la saisie dans les champs).
|
||
late final Future<ImageDimensions> _desktopRiverLogoDimensionsFuture;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
||
_emailFocus = FocusNode();
|
||
_passwordFocus = FocusNode();
|
||
_emailFocus.addListener(_onEmailFocusChange);
|
||
}
|
||
|
||
void _onEmailFocusChange() {
|
||
if (_emailFocus.hasFocus) {
|
||
return;
|
||
}
|
||
// Reporter au frame suivant : une mise à jour synchrone du controller pendant
|
||
// un transfert de focus (Tab) peut casser la navigation au clavier.
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted || _emailFocus.hasFocus) {
|
||
return;
|
||
}
|
||
final normalized = normalizeEmailText(_emailController.text);
|
||
if (normalized != _emailController.text) {
|
||
_emailController.value = TextEditingValue(
|
||
text: normalized,
|
||
selection: TextSelection.collapsed(offset: normalized.length),
|
||
);
|
||
}
|
||
_emailFormKey.currentState?.validate();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_emailFocus.removeListener(_onEmailFocusChange);
|
||
_emailFocus.dispose();
|
||
_passwordFocus.dispose();
|
||
_emailController.dispose();
|
||
_passwordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
String? _validateEmail(String? value) {
|
||
final v = value?.trim() ?? '';
|
||
if (v.isEmpty) {
|
||
return 'Veuillez entrer votre adresse e-mail.';
|
||
}
|
||
if (!isValidEmailFormat(v)) {
|
||
return 'L’adresse e-mail n’est pas valide.';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validatePassword(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return 'Veuillez entrer votre mot de passe';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
void _handlePasswordSubmitted(String _) {
|
||
if (_isLoading) return;
|
||
_handleLogin();
|
||
}
|
||
|
||
/// Gère la connexion de l'utilisateur
|
||
Future<void> _handleLogin() async {
|
||
// Réinitialiser le message d'erreur
|
||
setState(() {
|
||
_errorMessage = null;
|
||
});
|
||
|
||
if (!(_formKey.currentState?.validate() ?? false)) {
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_isLoading = true;
|
||
});
|
||
|
||
try {
|
||
// Appeler le service d'authentification
|
||
final user = await AuthService.login(
|
||
_emailController.text.trim(),
|
||
_passwordController.text,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
|
||
// Vérifier si l'utilisateur doit changer son mot de passe
|
||
if (user.changementMdpObligatoire) {
|
||
if (!mounted) return;
|
||
|
||
// Afficher la modale de changement de mot de passe (non-dismissible)
|
||
final result = await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => const ChangePasswordDialog(),
|
||
);
|
||
|
||
// Si le changement de mot de passe a réussi, rafraîchir l'utilisateur
|
||
if (result == true && mounted) {
|
||
await AuthService.refreshCurrentUser();
|
||
}
|
||
}
|
||
|
||
if (!mounted) return;
|
||
|
||
// Laisse au navigateur/OS la possibilité de mémoriser les identifiants.
|
||
TextInput.finishAutofillContext(shouldSave: true);
|
||
|
||
// Rediriger selon le rôle de l'utilisateur
|
||
_redirectUserByRole(user.role.isEmpty ? null : user.role);
|
||
} catch (e) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
||
void _redirectUserByRole(String? role) {
|
||
setState(() => _isLoading = false);
|
||
final r = (role ?? '').toLowerCase();
|
||
switch (r) {
|
||
case 'super_admin':
|
||
case 'administrateur':
|
||
context.go('/admin-dashboard');
|
||
break;
|
||
case 'gestionnaire':
|
||
context.go('/gestionnaire-dashboard');
|
||
break;
|
||
case 'parent':
|
||
context.go('/parent-dashboard');
|
||
break;
|
||
case 'assistante_maternelle':
|
||
context.go('/am-dashboard');
|
||
break;
|
||
default:
|
||
context.go('/home');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isMobile = MediaQuery.of(context).size.width < _mobileBreakpoint;
|
||
if (isMobile) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: _buildMobileLayout(context),
|
||
);
|
||
}
|
||
return Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final w = constraints.maxWidth;
|
||
final h = constraints.maxHeight;
|
||
return FutureBuilder<ImageDimensions>(
|
||
future: _desktopRiverLogoDimensionsFuture,
|
||
builder: (context, snapshot) {
|
||
if (!snapshot.hasData) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
final imageDimensions = snapshot.data!;
|
||
final imageHeight = h;
|
||
final imageWidth = imageHeight *
|
||
(imageDimensions.width / imageDimensions.height);
|
||
final remainingWidth = w - imageWidth;
|
||
final leftMargin = remainingWidth / 4;
|
||
|
||
return Stack(
|
||
children: [
|
||
// Fond en papier
|
||
Positioned.fill(
|
||
child: Image.asset(
|
||
'assets/images/paper2.png',
|
||
fit: BoxFit.cover,
|
||
repeat: ImageRepeat.repeat,
|
||
),
|
||
),
|
||
// Image principale
|
||
Positioned(
|
||
left: leftMargin,
|
||
top: 0,
|
||
height: imageHeight,
|
||
width: imageWidth,
|
||
child: Image.asset(
|
||
'assets/images/river_logo_desktop.png',
|
||
fit: BoxFit.contain,
|
||
),
|
||
),
|
||
// Formulaire dans le cadran en bas à droite
|
||
Positioned(
|
||
right: 0,
|
||
bottom: 0,
|
||
width: w * 0.6, // 60% de la largeur de l'écran
|
||
height: h * 0.5, // 50% de la hauteur de l'écran
|
||
child: Padding(
|
||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||
child: AutofillGroup(
|
||
child: Form(
|
||
key: _formKey,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// Champs côte à côte
|
||
FocusTraversalGroup(
|
||
policy: OrderedTraversalPolicy(),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: FocusTraversalOrder(
|
||
order: const NumericFocusOrder(1),
|
||
child: CustomAppTextField(
|
||
formFieldKey: _emailFormKey,
|
||
controller: _emailController,
|
||
focusNode: _emailFocus,
|
||
labelText: 'Email',
|
||
hintText: 'Votre adresse email',
|
||
keyboardType:
|
||
TextInputType.emailAddress,
|
||
autocorrect: false,
|
||
enableSuggestions: false,
|
||
autofillHints: const [
|
||
AutofillHints.username,
|
||
AutofillHints.email,
|
||
],
|
||
inputFormatters: const [
|
||
EmailMaxLengthFormatter(),
|
||
],
|
||
textInputAction: TextInputAction.next,
|
||
onFieldSubmitted: (_) =>
|
||
_passwordFocus.requestFocus(),
|
||
validator: _validateEmail,
|
||
style:
|
||
CustomAppTextFieldStyle.lavande,
|
||
fieldHeight: 53,
|
||
fieldWidth: double.infinity,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
Expanded(
|
||
child: FocusTraversalOrder(
|
||
order: const NumericFocusOrder(2),
|
||
child: CustomAppTextField(
|
||
controller: _passwordController,
|
||
focusNode: _passwordFocus,
|
||
labelText: 'Mot de passe',
|
||
hintText: 'Votre mot de passe',
|
||
obscureText: true,
|
||
autofillHints: const [
|
||
AutofillHints.password,
|
||
],
|
||
textInputAction: TextInputAction.done,
|
||
onFieldSubmitted:
|
||
_handlePasswordSubmitted,
|
||
validator: _validatePassword,
|
||
style:
|
||
CustomAppTextFieldStyle.jaune,
|
||
fieldHeight: 53,
|
||
fieldWidth: double.infinity,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Message d'erreur
|
||
if (_errorMessage != null)
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
margin: const EdgeInsets.only(bottom: 15),
|
||
decoration: BoxDecoration(
|
||
color: Colors.red[50],
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: Colors.red[300]!),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.error_outline,
|
||
color: Colors.red[700], size: 20),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
_errorMessage!,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 12,
|
||
color: Colors.red[700],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Bouton centré
|
||
Center(
|
||
child: _isLoading
|
||
? const CircularProgressIndicator()
|
||
: ImageButton(
|
||
bg: 'assets/images/bg_green.png',
|
||
width: 300,
|
||
height: 40,
|
||
text: 'Se connecter',
|
||
textColor: const Color(0xFF2D6A4F),
|
||
onPressed: _handleLogin,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
// Lien mot de passe oublié
|
||
Center(
|
||
child: TextButton(
|
||
onPressed: () {
|
||
context.go('/forgot-password');
|
||
},
|
||
child: Text(
|
||
'Mot de passe oublié ?',
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 14,
|
||
color: const Color(0xFF2D6A4F),
|
||
decoration: TextDecoration.underline,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
// Lien de création de compte (version originale)
|
||
Center(
|
||
child: TextButton(
|
||
onPressed: () {
|
||
context.go('/register-choice');
|
||
},
|
||
child: Text(
|
||
'Créer un compte',
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 16,
|
||
color: const Color(0xFF2D6A4F),
|
||
decoration: TextDecoration.underline,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||
decoration: const BoxDecoration(
|
||
color: Colors.transparent,
|
||
),
|
||
child: Wrap(
|
||
alignment: WrapAlignment.center,
|
||
runSpacing: 8,
|
||
children: [
|
||
_FooterLink(
|
||
text: 'Contact support',
|
||
onTap: () async {
|
||
final Uri emailLaunchUri = Uri(
|
||
scheme: 'mailto',
|
||
path: 'support@supernounou.local',
|
||
);
|
||
if (await canLaunchUrl(emailLaunchUri)) {
|
||
await launchUrl(emailLaunchUri);
|
||
} else {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Impossible d\'ouvrir le client mail',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
_FooterLink(
|
||
text: 'Signaler un bug',
|
||
onTap: () {
|
||
_showBugReportDialog(context);
|
||
},
|
||
),
|
||
_FooterLink(
|
||
text: 'Mentions légales',
|
||
onTap: () {
|
||
context.go('/legal');
|
||
},
|
||
),
|
||
_FooterLink(
|
||
text: 'Politique de confidentialité',
|
||
onTap: () {
|
||
context.go('/privacy');
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
||
static const int _riverLogoMobileWidth = 600;
|
||
static const int _riverLogoMobileHeight = 1080;
|
||
|
||
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
||
static const double _sloganEndFraction = 0.42;
|
||
static const double _gapBelowSlogan = 12.0;
|
||
|
||
Widget _buildMobileLayout(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final h = constraints.maxHeight;
|
||
final w = constraints.maxWidth;
|
||
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
||
final formTop =
|
||
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Positioned.fill(
|
||
child: Image.asset(
|
||
'assets/images/paper2.png',
|
||
fit: BoxFit.cover,
|
||
repeat: ImageRepeat.repeat,
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: h * 1.2,
|
||
child: OverflowBox(
|
||
alignment: Alignment.topCenter,
|
||
minWidth: w,
|
||
maxWidth: w,
|
||
minHeight: 0,
|
||
maxHeight: h * 2.5,
|
||
child: Image.asset(
|
||
'assets/images/river_logo_mobile.png',
|
||
width: w,
|
||
fit: BoxFit.fitWidth,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: formTop,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: SafeArea(
|
||
top: false,
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 24, vertical: 20),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 400),
|
||
child: AutofillGroup(
|
||
child: Form(
|
||
key: _formKey,
|
||
child: FocusTraversalGroup(
|
||
policy: OrderedTraversalPolicy(),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 16),
|
||
FocusTraversalOrder(
|
||
order: const NumericFocusOrder(1),
|
||
child: CustomAppTextField(
|
||
formFieldKey: _emailFormKey,
|
||
controller: _emailController,
|
||
focusNode: _emailFocus,
|
||
labelText: 'Email',
|
||
showLabel: false,
|
||
hintText: 'Votre adresse email',
|
||
keyboardType:
|
||
TextInputType.emailAddress,
|
||
autocorrect: false,
|
||
enableSuggestions: false,
|
||
autofillHints: const [
|
||
AutofillHints.username,
|
||
AutofillHints.email,
|
||
],
|
||
inputFormatters: const [
|
||
EmailMaxLengthFormatter(),
|
||
],
|
||
textInputAction: TextInputAction.next,
|
||
onFieldSubmitted: (_) =>
|
||
_passwordFocus.requestFocus(),
|
||
validator: _validateEmail,
|
||
style:
|
||
CustomAppTextFieldStyle.lavande,
|
||
fieldHeight: 48,
|
||
fieldWidth: double.infinity,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
FocusTraversalOrder(
|
||
order: const NumericFocusOrder(2),
|
||
child: CustomAppTextField(
|
||
controller: _passwordController,
|
||
focusNode: _passwordFocus,
|
||
labelText: 'Mot de passe',
|
||
showLabel: false,
|
||
hintText: 'Votre mot de passe',
|
||
obscureText: true,
|
||
autofillHints: const [
|
||
AutofillHints.password
|
||
],
|
||
textInputAction: TextInputAction.done,
|
||
onFieldSubmitted:
|
||
_handlePasswordSubmitted,
|
||
validator: _validatePassword,
|
||
style:
|
||
CustomAppTextFieldStyle.jaune,
|
||
fieldHeight: 48,
|
||
fieldWidth: double.infinity,
|
||
),
|
||
),
|
||
if (_errorMessage != null) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.red.shade50,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: Colors.red.shade300),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.error_outline,
|
||
color: Colors.red.shade700,
|
||
size: 20),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
_errorMessage!,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 12,
|
||
color: Colors.red.shade700),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 12),
|
||
_isLoading
|
||
? const CircularProgressIndicator()
|
||
: ImageButton(
|
||
bg: 'assets/images/bg_green.png',
|
||
width: double.infinity,
|
||
height: 44,
|
||
text: 'Se connecter',
|
||
textColor: const Color(0xFF2D6A4F),
|
||
onPressed: _handleLogin,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextButton(
|
||
onPressed: () => context.go('/forgot-password'),
|
||
child: Text(
|
||
'Mot de passe oublié ?',
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 14,
|
||
color: const Color(0xFF2D6A4F),
|
||
decoration: TextDecoration.underline,
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () =>
|
||
context.go('/register-choice'),
|
||
child: Text(
|
||
'Créer un compte',
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 16,
|
||
color: const Color(0xFF2D6A4F),
|
||
decoration: TextDecoration.underline,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12, top: 8),
|
||
child: Wrap(
|
||
alignment: WrapAlignment.center,
|
||
runSpacing: 6,
|
||
spacing: 4,
|
||
children: [
|
||
_FooterLink(
|
||
text: 'Contact support',
|
||
fontSize: 11,
|
||
onTap: () async {
|
||
final uri = Uri(
|
||
scheme: 'mailto',
|
||
path: 'support@supernounou.local');
|
||
if (await canLaunchUrl(uri)) {
|
||
await launchUrl(uri);
|
||
} else if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Impossible d\'ouvrir le client mail',
|
||
style: GoogleFonts.merienda())),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
_FooterLink(
|
||
text: 'Signaler un bug',
|
||
fontSize: 11,
|
||
onTap: () => _showBugReportDialog(context),
|
||
),
|
||
_FooterLink(
|
||
text: 'Mentions légales',
|
||
fontSize: 11,
|
||
onTap: () => context.go('/legal'),
|
||
),
|
||
_FooterLink(
|
||
text: 'Politique de confidentialité',
|
||
fontSize: 11,
|
||
onTap: () => context.go('/privacy'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _showBugReportDialog(BuildContext context) {
|
||
final TextEditingController controller = TextEditingController();
|
||
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text(
|
||
'Signaler un bug',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextField(
|
||
controller: controller,
|
||
maxLines: 5,
|
||
decoration: InputDecoration(
|
||
hintText: 'Décrivez le problème rencontré...',
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(
|
||
'Annuler',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () async {
|
||
if (controller.text.trim().isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Veuillez décrire le problème',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await BugReportService.sendReport(controller.text);
|
||
if (context.mounted) {
|
||
Navigator.pop(context);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Rapport envoyé avec succès',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Erreur lors de l\'envoi du rapport',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
child: Text(
|
||
'Envoyer',
|
||
style: GoogleFonts.merienda(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<ImageDimensions> _getImageDimensions() async {
|
||
final image = Image.asset('assets/images/river_logo_desktop.png');
|
||
final completer = Completer<ImageDimensions>();
|
||
image.image.resolve(const ImageConfiguration()).addListener(
|
||
ImageStreamListener((info, _) {
|
||
completer.complete(ImageDimensions(
|
||
width: info.image.width.toDouble(),
|
||
height: info.image.height.toDouble(),
|
||
));
|
||
}),
|
||
);
|
||
return completer.future;
|
||
}
|
||
}
|
||
|
||
class ImageDimensions {
|
||
final double width;
|
||
final double height;
|
||
|
||
ImageDimensions({required this.width, required this.height});
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Lien du pied de page
|
||
// ───────────────────────────────────────────────────────────────
|
||
class _FooterLink extends StatelessWidget {
|
||
final String text;
|
||
final VoidCallback onTap;
|
||
final double fontSize;
|
||
|
||
const _FooterLink({
|
||
required this.text,
|
||
required this.onTap,
|
||
this.fontSize = 14.0,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: fontSize > 12 ? 8.0 : 4.0),
|
||
child: Text(
|
||
text,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: fontSize,
|
||
color: Colors.black87,
|
||
decoration: TextDecoration.underline,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|