Uniformise l'identité visuelle des rôles (admin, super admin, gestionnaire, parent) avec icônes dédiées dans les listes et la modale, et affiche le téléphone dans la ligne admin en retirant le rôle redondant. Co-authored-by: Cursor <cursoragent@cursor.com>
688 lines
21 KiB
Dart
688 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:p_tits_pas/models/relais_model.dart';
|
||
import 'package:p_tits_pas/models/user.dart';
|
||
import 'package:p_tits_pas/services/relais_service.dart';
|
||
import 'package:p_tits_pas/services/user_service.dart';
|
||
|
||
class AdminUserFormDialog extends StatefulWidget {
|
||
final AppUser? initialUser;
|
||
final bool withRelais;
|
||
final bool adminMode;
|
||
final bool readOnly;
|
||
|
||
const AdminUserFormDialog({
|
||
super.key,
|
||
this.initialUser,
|
||
this.withRelais = true,
|
||
this.adminMode = false,
|
||
this.readOnly = false,
|
||
});
|
||
|
||
@override
|
||
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
|
||
}
|
||
|
||
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
final _nomController = TextEditingController();
|
||
final _prenomController = TextEditingController();
|
||
final _emailController = TextEditingController();
|
||
final _passwordController = TextEditingController();
|
||
final _telephoneController = TextEditingController();
|
||
final _passwordToggleFocusNode =
|
||
FocusNode(skipTraversal: true, canRequestFocus: false);
|
||
|
||
bool _isSubmitting = false;
|
||
bool _obscurePassword = true;
|
||
bool _isLoadingRelais = true;
|
||
List<RelaisModel> _relais = [];
|
||
String? _selectedRelaisId;
|
||
bool get _isEditMode => widget.initialUser != null;
|
||
bool get _isSuperAdminTarget =>
|
||
widget.initialUser?.role.toLowerCase() == 'super_admin';
|
||
bool get _isLockedAdminIdentity =>
|
||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||
String get _targetRoleKey {
|
||
if (widget.initialUser != null) {
|
||
return widget.initialUser!.role.toLowerCase();
|
||
}
|
||
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
||
}
|
||
|
||
String get _targetRoleLabel {
|
||
switch (_targetRoleKey) {
|
||
case 'super_admin':
|
||
return 'Super administrateur';
|
||
case 'administrateur':
|
||
return 'Administrateur';
|
||
case 'gestionnaire':
|
||
return 'Gestionnaire';
|
||
case 'assistante_maternelle':
|
||
return 'Assistante maternelle';
|
||
case 'parent':
|
||
return 'Parent';
|
||
default:
|
||
return 'Utilisateur';
|
||
}
|
||
}
|
||
|
||
IconData get _targetRoleIcon {
|
||
switch (_targetRoleKey) {
|
||
case 'super_admin':
|
||
return Icons.verified_user_outlined;
|
||
case 'administrateur':
|
||
return Icons.admin_panel_settings_outlined;
|
||
case 'gestionnaire':
|
||
return Icons.assignment_ind_outlined;
|
||
case 'assistante_maternelle':
|
||
return Icons.child_care_outlined;
|
||
case 'parent':
|
||
return Icons.supervisor_account_outlined;
|
||
default:
|
||
return Icons.person_outline;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final user = widget.initialUser;
|
||
if (user != null) {
|
||
_nomController.text = user.nom ?? '';
|
||
_prenomController.text = user.prenom ?? '';
|
||
_emailController.text = user.email;
|
||
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
|
||
// En édition, on ne préremplit jamais le mot de passe.
|
||
_passwordController.clear();
|
||
final initialRelaisId = user.relaisId?.trim();
|
||
_selectedRelaisId =
|
||
(initialRelaisId == null || initialRelaisId.isEmpty)
|
||
? null
|
||
: initialRelaisId;
|
||
}
|
||
if (widget.withRelais) {
|
||
_loadRelais();
|
||
} else {
|
||
_isLoadingRelais = false;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nomController.dispose();
|
||
_prenomController.dispose();
|
||
_emailController.dispose();
|
||
_passwordController.dispose();
|
||
_telephoneController.dispose();
|
||
_passwordToggleFocusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadRelais() async {
|
||
try {
|
||
final list = await RelaisService.getRelais();
|
||
if (!mounted) return;
|
||
final uniqueById = <String, RelaisModel>{};
|
||
for (final relais in list) {
|
||
uniqueById[relais.id] = relais;
|
||
}
|
||
|
||
final filtered = uniqueById.values.where((r) => r.actif).toList();
|
||
if (_selectedRelaisId != null &&
|
||
!filtered.any((r) => r.id == _selectedRelaisId)) {
|
||
final selected = uniqueById[_selectedRelaisId!];
|
||
if (selected != null) {
|
||
filtered.add(selected);
|
||
} else {
|
||
_selectedRelaisId = null;
|
||
}
|
||
}
|
||
|
||
setState(() {
|
||
_relais = filtered;
|
||
_isLoadingRelais = false;
|
||
});
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_selectedRelaisId = null;
|
||
_relais = [];
|
||
_isLoadingRelais = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
String? _required(String? value, String field) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return '$field est requis';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validateEmail(String? value) {
|
||
final base = _required(value, 'Email');
|
||
if (base != null) return base;
|
||
final email = value!.trim();
|
||
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||
if (!ok) return 'Format email invalide';
|
||
return null;
|
||
}
|
||
|
||
String? _validatePassword(String? value) {
|
||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||
return null;
|
||
}
|
||
final base = _required(value, 'Mot de passe');
|
||
if (base != null) return base;
|
||
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
||
return null;
|
||
}
|
||
|
||
String? _validatePhone(String? value) {
|
||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||
return null;
|
||
}
|
||
final base = _required(value, 'Téléphone');
|
||
if (base != null) return base;
|
||
final digits = _normalizePhone(value!);
|
||
if (digits.length != 10) {
|
||
return 'Le téléphone doit contenir 10 chiffres';
|
||
}
|
||
if (!digits.startsWith('0')) {
|
||
return 'Le téléphone doit commencer par 0';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String _normalizePhone(String raw) {
|
||
return raw.replaceAll(RegExp(r'\D'), '');
|
||
}
|
||
|
||
String _formatPhoneForDisplay(String raw) {
|
||
final normalized = _normalizePhone(raw);
|
||
final digits =
|
||
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
|
||
final buffer = StringBuffer();
|
||
for (var i = 0; i < digits.length; i++) {
|
||
if (i > 0 && i.isEven) buffer.write(' ');
|
||
buffer.write(digits[i]);
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
String _toTitleCase(String raw) {
|
||
final trimmed = raw.trim();
|
||
if (trimmed.isEmpty) return trimmed;
|
||
final words = trimmed.split(RegExp(r'\s+'));
|
||
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
||
return normalizedWords.join(' ');
|
||
}
|
||
|
||
String _capitalizeComposedWord(String word) {
|
||
if (word.isEmpty) return word;
|
||
final lower = word.toLowerCase();
|
||
final separators = <String>{"-", "'", "’"};
|
||
final buffer = StringBuffer();
|
||
var capitalizeNext = true;
|
||
|
||
for (var i = 0; i < lower.length; i++) {
|
||
final char = lower[i];
|
||
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
||
buffer.write(char.toUpperCase());
|
||
capitalizeNext = false;
|
||
} else {
|
||
buffer.write(char);
|
||
capitalizeNext = separators.contains(char);
|
||
}
|
||
}
|
||
return buffer.toString();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
if (widget.readOnly) return;
|
||
if (_isSubmitting) return;
|
||
if (!_formKey.currentState!.validate()) return;
|
||
|
||
setState(() {
|
||
_isSubmitting = true;
|
||
});
|
||
|
||
try {
|
||
final normalizedNom = _toTitleCase(_nomController.text);
|
||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||
|
||
if (_isEditMode) {
|
||
if (widget.adminMode) {
|
||
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
|
||
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
|
||
await UserService.updateAdministrateur(
|
||
adminId: widget.initialUser!.id,
|
||
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
|
||
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
telephone: normalizedPhone.isEmpty
|
||
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
||
: normalizedPhone,
|
||
password: passwordProvided ? _passwordController.text : null,
|
||
);
|
||
} else {
|
||
final currentUser = widget.initialUser!;
|
||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||
final initialEmail = currentUser.email.trim();
|
||
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
||
|
||
final onlyRelaisChanged =
|
||
normalizedNom == initialNom &&
|
||
normalizedPrenom == initialPrenom &&
|
||
_emailController.text.trim() == initialEmail &&
|
||
normalizedPhone == initialPhone &&
|
||
!passwordProvided;
|
||
|
||
if (onlyRelaisChanged) {
|
||
await UserService.updateGestionnaireRelais(
|
||
gestionnaireId: currentUser.id,
|
||
relaisId: _selectedRelaisId,
|
||
);
|
||
} else {
|
||
await UserService.updateGestionnaire(
|
||
gestionnaireId: currentUser.id,
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
||
relaisId: _selectedRelaisId,
|
||
password: passwordProvided ? _passwordController.text : null,
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
if (widget.adminMode) {
|
||
await UserService.createAdministrateur(
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
password: _passwordController.text,
|
||
telephone: _normalizePhone(_telephoneController.text),
|
||
);
|
||
} else {
|
||
await UserService.createGestionnaire(
|
||
nom: normalizedNom,
|
||
prenom: normalizedPrenom,
|
||
email: _emailController.text.trim(),
|
||
password: _passwordController.text,
|
||
telephone: _normalizePhone(_telephoneController.text),
|
||
relaisId: _selectedRelaisId,
|
||
);
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
_isEditMode
|
||
? (widget.adminMode
|
||
? 'Administrateur modifié avec succès.'
|
||
: 'Gestionnaire modifié avec succès.')
|
||
: (widget.adminMode
|
||
? 'Administrateur créé avec succès.'
|
||
: 'Gestionnaire créé avec succès.'),
|
||
),
|
||
),
|
||
);
|
||
Navigator.of(context).pop(true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
e.toString().replaceFirst('Exception: ', ''),
|
||
),
|
||
backgroundColor: Colors.red.shade700,
|
||
),
|
||
);
|
||
} finally {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isSubmitting = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _delete() async {
|
||
if (widget.readOnly) return;
|
||
if (_isSuperAdminTarget) return;
|
||
if (!_isEditMode || _isSubmitting) return;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) {
|
||
return AlertDialog(
|
||
title: const Text('Confirmer la suppression'),
|
||
content: Text(
|
||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||
child: const Text('Supprimer'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
|
||
if (confirmed != true) return;
|
||
|
||
setState(() {
|
||
_isSubmitting = true;
|
||
});
|
||
try {
|
||
await UserService.deleteUser(widget.initialUser!.id);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
||
);
|
||
Navigator.of(context).pop(true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||
backgroundColor: Colors.red.shade700,
|
||
),
|
||
);
|
||
setState(() {
|
||
_isSubmitting = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 16,
|
||
backgroundColor: const Color(0xFFEDE5FA),
|
||
child: Icon(
|
||
_targetRoleIcon,
|
||
size: 20,
|
||
color: const Color(0xFF6B3FA0),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
_isEditMode
|
||
? (widget.readOnly
|
||
? 'Consulter un "$_targetRoleLabel"'
|
||
: 'Modifier un "$_targetRoleLabel"')
|
||
: 'Créer un "$_targetRoleLabel"',
|
||
),
|
||
),
|
||
if (_isEditMode && !widget.readOnly)
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
tooltip: 'Fermer',
|
||
onPressed: _isSubmitting
|
||
? null
|
||
: () => Navigator.of(context).pop(false),
|
||
),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 620,
|
||
child: Form(
|
||
key: _formKey,
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(child: _buildPrenomField()),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: _buildNomField()),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildEmailField(),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(child: _buildPasswordField()),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: _buildTelephoneField()),
|
||
],
|
||
),
|
||
if (widget.withRelais) ...[
|
||
const SizedBox(height: 12),
|
||
_buildRelaisField(),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
if (widget.readOnly) ...[
|
||
FilledButton(
|
||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||
child: const Text('Fermer'),
|
||
),
|
||
] else if (_isEditMode) ...[
|
||
if (!_isSuperAdminTarget)
|
||
OutlinedButton(
|
||
onPressed: _isSubmitting ? null : _delete,
|
||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||
child: const Text('Supprimer'),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: _isSubmitting ? null : _submit,
|
||
icon: _isSubmitting
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.edit),
|
||
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
||
),
|
||
] else ...[
|
||
OutlinedButton(
|
||
onPressed:
|
||
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: _isSubmitting ? null : _submit,
|
||
icon: _isSubmitting
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.person_add_alt_1),
|
||
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildNomField() {
|
||
return TextFormField(
|
||
controller: _nomController,
|
||
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
||
textCapitalization: TextCapitalization.words,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Nom',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: (widget.readOnly || _isLockedAdminIdentity)
|
||
? null
|
||
: (v) => _required(v, 'Nom'),
|
||
);
|
||
}
|
||
|
||
Widget _buildPrenomField() {
|
||
return TextFormField(
|
||
controller: _prenomController,
|
||
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
||
textCapitalization: TextCapitalization.words,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Prénom',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: (widget.readOnly || _isLockedAdminIdentity)
|
||
? null
|
||
: (v) => _required(v, 'Prénom'),
|
||
);
|
||
}
|
||
|
||
Widget _buildEmailField() {
|
||
return TextFormField(
|
||
controller: _emailController,
|
||
readOnly: widget.readOnly,
|
||
keyboardType: TextInputType.emailAddress,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Email',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: widget.readOnly ? null : _validateEmail,
|
||
);
|
||
}
|
||
|
||
Widget _buildPasswordField() {
|
||
return TextFormField(
|
||
controller: _passwordController,
|
||
readOnly: widget.readOnly,
|
||
obscureText: _obscurePassword,
|
||
enableSuggestions: false,
|
||
autocorrect: false,
|
||
autofillHints: _isEditMode
|
||
? const <String>[]
|
||
: const [AutofillHints.newPassword],
|
||
decoration: InputDecoration(
|
||
labelText: _isEditMode
|
||
? 'Nouveau mot de passe'
|
||
: 'Mot de passe',
|
||
border: const OutlineInputBorder(),
|
||
suffixIcon: widget.readOnly
|
||
? null
|
||
: ExcludeFocus(
|
||
child: IconButton(
|
||
focusNode: _passwordToggleFocusNode,
|
||
onPressed: () {
|
||
setState(() {
|
||
_obscurePassword = !_obscurePassword;
|
||
});
|
||
},
|
||
icon: Icon(
|
||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
validator: widget.readOnly ? null : _validatePassword,
|
||
);
|
||
}
|
||
|
||
Widget _buildTelephoneField() {
|
||
return TextFormField(
|
||
controller: _telephoneController,
|
||
readOnly: widget.readOnly,
|
||
keyboardType: TextInputType.phone,
|
||
inputFormatters: widget.readOnly
|
||
? null
|
||
: [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(10),
|
||
_FrenchPhoneNumberFormatter(),
|
||
],
|
||
decoration: const InputDecoration(
|
||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
validator: widget.readOnly ? null : _validatePhone,
|
||
);
|
||
}
|
||
|
||
Widget _buildRelaisField() {
|
||
final selectedValue = _selectedRelaisId != null &&
|
||
_relais.any((relais) => relais.id == _selectedRelaisId)
|
||
? _selectedRelaisId
|
||
: null;
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
DropdownButtonFormField<String?>(
|
||
isExpanded: true,
|
||
value: selectedValue,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Relais principal',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
const DropdownMenuItem<String?>(
|
||
value: null,
|
||
child: Text('Aucun relais'),
|
||
),
|
||
..._relais.map(
|
||
(relais) => DropdownMenuItem<String?>(
|
||
value: relais.id,
|
||
child: Text(relais.nom),
|
||
),
|
||
),
|
||
],
|
||
onChanged: (_isLoadingRelais || widget.readOnly)
|
||
? null
|
||
: (value) {
|
||
setState(() {
|
||
_selectedRelaisId = value;
|
||
});
|
||
},
|
||
),
|
||
if (_isLoadingRelais) ...[
|
||
const SizedBox(height: 8),
|
||
const LinearProgressIndicator(minHeight: 2),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||
const _FrenchPhoneNumberFormatter();
|
||
|
||
@override
|
||
TextEditingValue formatEditUpdate(
|
||
TextEditingValue oldValue,
|
||
TextEditingValue newValue,
|
||
) {
|
||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||
final buffer = StringBuffer();
|
||
for (var i = 0; i < normalized.length; i++) {
|
||
if (i > 0 && i.isEven) buffer.write(' ');
|
||
buffer.write(normalized[i]);
|
||
}
|
||
final formatted = buffer.toString();
|
||
|
||
return TextEditingValue(
|
||
text: formatted,
|
||
selection: TextSelection.collapsed(offset: formatted.length),
|
||
);
|
||
}
|
||
} |