docs(mobile): documentation complète Spec 001 + architecture
Documentation ajoutée : - ARCHITECTURE.md : Clean Architecture par feature, BLoC pattern - OPTIMISATIONS_PERFORMANCE.md : Cache multi-niveaux, pagination, lazy loading - SECURITE_PRODUCTION.md : FlutterSecureStorage, JWT, HTTPS, ProGuard - CHANGELOG.md : Historique versions - CONTRIBUTING.md : Guide contribution - README.md : Mise à jour (build, env config) Widgets partagés : - file_upload_widget.dart : Upload fichiers (photos/PDFs) Cache : - lib/core/cache/ : Système cache L1/L2 (mémoire/disque) Dependencies : - pubspec.yaml : file_picker 8.1.2, injectable, dio Spec 001 : 27/27 tâches (100%) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
255
lib/shared/widgets/file_upload_widget.dart
Normal file
255
lib/shared/widgets/file_upload_widget.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// Widget réutilisable pour uploader un fichier (image ou PDF)
|
||||
/// avec prévisualisation et validation
|
||||
class FileUploadWidget extends StatefulWidget {
|
||||
final Function(File?) onFileSelected;
|
||||
final String? initialFileName;
|
||||
final bool showImagePreview;
|
||||
|
||||
const FileUploadWidget({
|
||||
super.key,
|
||||
required this.onFileSelected,
|
||||
this.initialFileName,
|
||||
this.showImagePreview = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FileUploadWidget> createState() => _FileUploadWidgetState();
|
||||
}
|
||||
|
||||
class _FileUploadWidgetState extends State<FileUploadWidget> {
|
||||
File? _selectedFile;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialFileName != null) {
|
||||
_selectedFile = File(widget.initialFileName!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
final file = File(image.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
// Vérifier la taille (max 5 MB)
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
_showError('Fichier trop volumineux. Taille max: 5 MB');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedFile = file;
|
||||
});
|
||||
widget.onFileSelected(file);
|
||||
}
|
||||
} catch (e) {
|
||||
_showError('Erreur lors de la sélection de l\'image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickPdf() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final fileSize = await file.length();
|
||||
|
||||
// Vérifier la taille (max 5 MB)
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
_showError('Fichier trop volumineux. Taille max: 5 MB');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedFile = file;
|
||||
});
|
||||
widget.onFileSelected(file);
|
||||
}
|
||||
} catch (e) {
|
||||
_showError('Erreur lors de la sélection du PDF: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _removeFile() {
|
||||
setState(() {
|
||||
_selectedFile = null;
|
||||
});
|
||||
widget.onFileSelected(null);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showPickerOptions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera),
|
||||
title: const Text('Prendre une photo'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Choisir une image'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.picture_as_pdf),
|
||||
title: const Text('Choisir un PDF'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickPdf();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isImage() {
|
||||
if (_selectedFile == null) return false;
|
||||
final ext = _selectedFile!.path.split('.').last.toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png', 'gif'].contains(ext);
|
||||
}
|
||||
|
||||
bool _isPdf() {
|
||||
if (_selectedFile == null) return false;
|
||||
return _selectedFile!.path.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Pièce justificative',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (_selectedFile != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: _removeFile,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_selectedFile == null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: _showPickerOptions,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: const Text('Joindre un fichier'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
// Prévisualisation
|
||||
if (_isImage() && widget.showImagePreview)
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
_selectedFile!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_isPdf())
|
||||
Container(
|
||||
height: 100,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.picture_as_pdf, size: 48, color: Colors.red),
|
||||
SizedBox(height: 8),
|
||||
Text('Document PDF'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_selectedFile!.path.split('/').last,
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Formats acceptés: JPEG, PNG, PDF (max 5 MB)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user