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:
dahoud
2026-03-16 05:15:38 +00:00
parent 775729b4c3
commit 5c5ec3ad00
10 changed files with 3607 additions and 154 deletions

View 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,
),
),
],
),
),
);
}
}