Files
impact/lib/features/capture/capture_screen.dart
qguillaume 3b2172e3ec feat: integration mlkit Document Scanner pour la prise de photo
Remplace la camera maison par le scanner de documents Google ML Kit
qui detecte les bords de la cible et la redresse de face automatiquement
(mode base = sans filtre couleur, pour conserver les couleurs reelles).

- nouveau service DocumentScannerService (scanTarget)
- capture_screen branche sur le scanner mlkit, reglages galerie 1280/q75 conserves
- AndroidManifest: meta-data com.google.mlkit.vision.DEPENDENCIES=docscanner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:20:04 +02:00

270 lines
9.4 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart';
import '../session/session_provider.dart';
import 'widgets/image_source_button.dart';
import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@override
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker();
final TargetType _selectedType = TargetType.concentric;
// Scanner de documents Google ML Kit (redresse la cible de face automatiquement)
final DocumentScannerService _docScanner = DocumentScannerService();
String? _selectedImagePath;
bool _isLoading = false;
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.document_scanner,
label: 'Scanner',
onPressed: _isLoading ? null : _scanWithDocumentScanner,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else
_buildGuide(),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Déclic → lance le scanner de documents Google ML Kit qui redresse
// automatiquement la cible (feuille rectangulaire) de face.
// ─────────────────────────────────────────────────────────────────────────
Future<void> _scanWithDocumentScanner() async {
setState(() => _isLoading = true);
try {
final result = await _docScanner.scanTarget();
if (!result.success) {
// L'utilisateur a annulé le scan : on ne fait rien de plus.
if (mounted) setState(() => _isLoading = false);
return;
}
setState(() {
_selectedImagePath = result.imagePath;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur scanner document: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le scan a échoué. Réessayez.'),
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Galerie (inchangée)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _handleGallerySelection() async {
PermissionStatus status;
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
status = androidInfo.version.sdkInt >= 33
? await Permission.photos.request()
: await Permission.storage.request();
} else {
status = await Permission.photos.request();
}
if (status.isGranted) {
_captureImageFromGallery();
} else if (status.isPermanentlyDenied) {
_showSettingsDialog();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Accès à la galerie requis pour continuer'),
),
);
}
}
}
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission requise'),
content: const Text(
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => openAppSettings(),
child: const Text('Paramètres'),
),
],
),
);
}
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
// PERF : limite d'import plus agressive pour accélérer le pipeline
maxWidth: 1280,
maxHeight: 1280,
imageQuality: 75,
);
if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur galerie: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CropScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
);
}
Widget _buildGuide() {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
'Conseils pour une bonne analyse',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildGuideItem(
Icons.crop_free,
'Cadrez la cible entière dans l\'image',
),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
_buildGuideItem(
Icons.cleaning_services,
'Nettoyer votre objectif avec une chiffonnette',
),
],
),
),
);
}
Widget _buildGuideItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 20, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
),
);
}
}