feat: Livraison version 3.0.6
- Amélioration de la gestion des entités et des utilisateurs - Mise à jour des modèles Amicale et Client avec champs supplémentaires - Ajout du service de logging et amélioration du chargement UI - Refactoring des formulaires utilisateur et amicale - Intégration de file_picker et image_picker pour la gestion des fichiers - Amélioration de la gestion des membres et de leur suppression - Optimisation des performances de l'API - Mise à jour de la documentation technique 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class AmicaleForm extends StatefulWidget {
|
||||
@@ -52,6 +58,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
bool _chkAcceptSms = false;
|
||||
bool _chkActive = true;
|
||||
bool _chkStripe = false;
|
||||
bool _chkMdpManuel = false;
|
||||
bool _chkUsernameManuel = false;
|
||||
|
||||
// Pour l'upload du logo
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
XFile? _selectedImage;
|
||||
String? _logoUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -78,6 +91,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
|
||||
_chkActive = amicale?.chkActive ?? true;
|
||||
_chkStripe = amicale?.chkStripe ?? false;
|
||||
_chkMdpManuel = amicale?.chkMdpManuel ?? false;
|
||||
_chkUsernameManuel = amicale?.chkUsernameManuel ?? false;
|
||||
|
||||
// Note : Le logo sera chargé dynamiquement depuis l'API
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -133,6 +150,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
'chk_copie_mail_recu': amicale.chkCopieMailRecu ? 1 : 0,
|
||||
'chk_accept_sms': amicale.chkAcceptSms ? 1 : 0,
|
||||
'chk_stripe': amicale.chkStripe ? 1 : 0,
|
||||
'chk_mdp_manuel': amicale.chkMdpManuel ? 1 : 0,
|
||||
'chk_username_manuel': amicale.chkUsernameManuel ? 1 : 0,
|
||||
};
|
||||
|
||||
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
|
||||
@@ -232,6 +251,115 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour sélectionner une image
|
||||
Future<void> _selectImage() async {
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
// Vérifier la taille du fichier (limite 5 Mo)
|
||||
final int fileSize = await image.length();
|
||||
const int maxSize = 5 * 1024 * 1024; // 5 Mo en octets
|
||||
|
||||
if (fileSize > maxSize) {
|
||||
// Fichier trop volumineux
|
||||
final double sizeMB = fileSize / (1024 * 1024);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Le fichier est trop volumineux (${sizeMB.toStringAsFixed(2)} Mo). '
|
||||
'La taille maximale autorisée est de 5 Mo.',
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedImage = image;
|
||||
});
|
||||
|
||||
// Upload immédiatement après sélection
|
||||
if (widget.amicale?.id != null) {
|
||||
await _uploadLogo();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sélection de l\'image: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sélection de l\'image: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour uploader le logo
|
||||
Future<void> _uploadLogo() async {
|
||||
if (_selectedImage == null || widget.amicale?.id == null) return;
|
||||
|
||||
OverlayEntry? spinOverlay;
|
||||
try {
|
||||
// Afficher le spinner
|
||||
spinOverlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Upload du logo en cours...',
|
||||
blurAmount: 10.0,
|
||||
showCard: true,
|
||||
);
|
||||
|
||||
// Appeler l'API pour uploader le logo
|
||||
final response = await widget.apiService?.uploadLogo(
|
||||
widget.amicale!.id,
|
||||
_selectedImage!,
|
||||
);
|
||||
|
||||
if (response != null && response['status'] == 'success') {
|
||||
// Succès
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logo uploadé avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mettre à jour l'amicale avec le nouveau logo en base64
|
||||
// Note : Le serveur devrait aussi mettre à jour le logo dans la session
|
||||
// Pour l'instant on garde l'image sélectionnée en preview
|
||||
setState(() {
|
||||
// L'image reste en preview jusqu'au prochain login
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'upload du logo: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de l\'upload: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Fermer le spinner
|
||||
LoadingSpinOverlayUtils.hideSpecific(spinOverlay);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
debugPrint('🔧 _submitForm appelée');
|
||||
|
||||
@@ -271,6 +399,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
) ??
|
||||
AmicaleModel(
|
||||
id: 0, // Sera remplacé par l'API
|
||||
@@ -291,6 +421,9 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
);
|
||||
|
||||
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
|
||||
@@ -305,6 +438,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
|
||||
// Construire la section logo
|
||||
Widget _buildLogoSection() {
|
||||
// Vérifier si on est admin d'amicale (role 2)
|
||||
final userRole = widget.userRepository.getUserRole();
|
||||
final canUploadLogo = userRole == 2 && !widget.readOnly;
|
||||
|
||||
return Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
@@ -323,40 +460,40 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Image par défaut
|
||||
// Afficher l'image sélectionnée, ou le logo depuis l'API, ou l'image par défaut
|
||||
Center(
|
||||
child: Image.asset(
|
||||
'assets/images/logo_recu.png',
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
child: _buildLogoImage(),
|
||||
),
|
||||
|
||||
// Overlay pour indiquer que l'image est modifiable (si non en lecture seule)
|
||||
if (!widget.readOnly)
|
||||
// Overlay pour indiquer que l'image est modifiable (si admin d'amicale)
|
||||
if (canUploadLogo)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Implémenter la sélection d'image
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Fonctionnalité de modification du logo à venir'),
|
||||
),
|
||||
);
|
||||
},
|
||||
onTap: _selectImage,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
color: Colors.white,
|
||||
size: 40,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.camera_alt,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Modifier',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -367,6 +504,102 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour construire l'image du logo
|
||||
Widget _buildLogoImage() {
|
||||
// 1. Si une image a été sélectionnée localement (preview)
|
||||
if (_selectedImage != null) {
|
||||
if (kIsWeb) {
|
||||
return FutureBuilder<Uint8List>(
|
||||
future: _selectedImage!.readAsBytes(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Image.memory(
|
||||
snapshot.data!,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
return const CircularProgressIndicator();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Image.file(
|
||||
File(_selectedImage!.path),
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si l'amicale a un logo en base64 stocké dans Hive
|
||||
if (widget.amicale?.logoBase64 != null && widget.amicale!.logoBase64!.isNotEmpty) {
|
||||
try {
|
||||
// Le logoBase64 contient déjà le data URL complet (data:image/png;base64,...)
|
||||
final dataUrl = widget.amicale!.logoBase64!;
|
||||
|
||||
// Extraire le base64 du data URL
|
||||
final base64Data = dataUrl.split(',').last;
|
||||
final bytes = base64Decode(base64Data);
|
||||
|
||||
return Image.memory(
|
||||
bytes,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
debugPrint('Erreur affichage logo base64: $error');
|
||||
// En cas d'erreur, essayer l'API
|
||||
return _buildLogoFromApi();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur décodage base64: $e');
|
||||
// En cas d'erreur, essayer l'API
|
||||
return _buildLogoFromApi();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sinon, essayer de charger depuis l'API
|
||||
return _buildLogoFromApi();
|
||||
}
|
||||
|
||||
// Méthode pour charger le logo depuis l'API
|
||||
Widget _buildLogoFromApi() {
|
||||
if (widget.amicale?.id != null && widget.apiService != null) {
|
||||
// Construire l'URL complète du logo
|
||||
final logoUrl = '${widget.apiService!.baseUrl}/entites/${widget.amicale!.id}/logo';
|
||||
|
||||
return Image.network(
|
||||
logoUrl,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
headers: {
|
||||
'Authorization': 'Bearer ${widget.apiService!.sessionId ?? ""}',
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// En cas d'erreur, afficher l'image par défaut
|
||||
return Image.asset(
|
||||
'assets/images/logo_recu.png',
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Par défaut, afficher l'image locale
|
||||
return Image.asset(
|
||||
'assets/images/logo_recu.png',
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
|
||||
// Construire la minimap
|
||||
Widget _buildMiniMap() {
|
||||
@@ -825,59 +1058,106 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Demo
|
||||
_buildCheckboxOption(
|
||||
label: "Mode démo",
|
||||
value: _chkDemo,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkDemo = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Options organisées sur 2 colonnes
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Colonne de gauche
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Checkbox Demo
|
||||
_buildCheckboxOption(
|
||||
label: "Mode démo",
|
||||
value: _chkDemo,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkDemo = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Copie Mail Reçu
|
||||
_buildCheckboxOption(
|
||||
label: "Copie des mails reçus",
|
||||
value: _chkCopieMailRecu,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkCopieMailRecu = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Checkbox Copie Mail Reçu
|
||||
_buildCheckboxOption(
|
||||
label: "Copie des mails reçus",
|
||||
value: _chkCopieMailRecu,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkCopieMailRecu = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Accept SMS
|
||||
_buildCheckboxOption(
|
||||
label: "Accepte les SMS",
|
||||
value: _chkAcceptSms,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkAcceptSms = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Checkbox Accept SMS
|
||||
_buildCheckboxOption(
|
||||
label: "Accepte les SMS",
|
||||
value: _chkAcceptSms,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkAcceptSms = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
// Colonne de droite
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Checkbox Active
|
||||
_buildCheckboxOption(
|
||||
label: "Actif",
|
||||
value: _chkActive,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkActive = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Active
|
||||
_buildCheckboxOption(
|
||||
label: "Actif",
|
||||
value: _chkActive,
|
||||
onChanged: restrictedFieldsReadOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkActive = value!;
|
||||
});
|
||||
},
|
||||
// Checkbox Mot de passe manuel
|
||||
_buildCheckboxOption(
|
||||
label: "Saisie manuelle des mots de passe",
|
||||
value: _chkMdpManuel,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkMdpManuel = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox Username manuel
|
||||
_buildCheckboxOption(
|
||||
label: "Saisie manuelle des identifiants",
|
||||
value: _chkUsernameManuel,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkUsernameManuel = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user