493 lines
17 KiB
Dart
493 lines
17 KiB
Dart
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../data/models/target_type.dart';
|
||
import '../../services/image_crop_service.dart';
|
||
import '../analysis/analysis_screen.dart';
|
||
import 'widgets/crop_overlay.dart';
|
||
|
||
class CropScreen extends StatefulWidget {
|
||
final String imagePath;
|
||
final TargetType targetType;
|
||
final double? initialScale;
|
||
final Offset? initialOffset;
|
||
|
||
const CropScreen({
|
||
super.key,
|
||
required this.imagePath,
|
||
required this.targetType,
|
||
this.initialScale,
|
||
this.initialOffset,
|
||
});
|
||
|
||
@override
|
||
State<CropScreen> createState() => _CropScreenState();
|
||
}
|
||
|
||
class _CropScreenState extends State<CropScreen> {
|
||
final ImageCropService _cropService = ImageCropService();
|
||
|
||
// États de transformation
|
||
double _scale = 1.0;
|
||
double _baseScale = 1.0;
|
||
Offset _offset = Offset.zero;
|
||
Offset _startOffset = Offset.zero;
|
||
Offset _startFocalPoint = Offset.zero;
|
||
|
||
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
|
||
double _rotation = 0.0;
|
||
|
||
bool _isLoading = false;
|
||
bool _imageLoaded = false;
|
||
Size? _imageSize;
|
||
late Size _viewportSize;
|
||
late double _cropSize;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadImageInfo();
|
||
}
|
||
|
||
Future<void> _loadImageInfo() async {
|
||
final file = File(widget.imagePath);
|
||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||
if (mounted) {
|
||
setState(() {
|
||
_imageSize = Size(
|
||
decodedImage.width.toDouble(),
|
||
decodedImage.height.toDouble(),
|
||
);
|
||
_imageLoaded = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFF101214),
|
||
appBar: AppBar(
|
||
backgroundColor: const Color(0xFF101214),
|
||
elevation: 0,
|
||
centerTitle: true,
|
||
title: const Text(
|
||
'Centrage de la cible',
|
||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||
),
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
actions: [
|
||
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||
tooltip: 'Réinitialiser l\'orientation',
|
||
onPressed: () {
|
||
setState(() {
|
||
_rotation = 0.0;
|
||
_scale = 1.0;
|
||
_offset = Offset.zero;
|
||
_initializeImagePosition();
|
||
});
|
||
},
|
||
),
|
||
],
|
||
),
|
||
body: _isLoading
|
||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||
: Column(
|
||
children: [
|
||
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: Text(
|
||
'Alignez et pivotez la cible sur la croix',
|
||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
|
||
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
|
||
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
|
||
// carrée, donc la cible n'est pas déformée.
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Center(
|
||
child: AspectRatio(
|
||
aspectRatio: 1.0,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Colors.white10),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 8),
|
||
|
||
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
|
||
_buildDirectionalPad(),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text(
|
||
'Rotation de l\'image',
|
||
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
|
||
),
|
||
Text(
|
||
'${_rotation.toStringAsFixed(1)}°',
|
||
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
|
||
),
|
||
],
|
||
),
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
|
||
Expanded(
|
||
child: Slider(
|
||
value: _rotation,
|
||
min: -15.0,
|
||
max: 15.0,
|
||
divisions: 300,
|
||
label: '${_rotation.toStringAsFixed(1)}°',
|
||
activeColor: const Color(0xFF1A73E8),
|
||
inactiveColor: Colors.white12,
|
||
onChanged: (double value) {
|
||
setState(() {
|
||
_rotation = value;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
|
||
const SizedBox(width: 4),
|
||
IconButton(
|
||
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||
onPressed: () {
|
||
setState(() {
|
||
_rotation = 0.0;
|
||
});
|
||
},
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Boutons du bas
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
style: OutlinedButton.styleFrom(
|
||
side: const BorderSide(color: Colors.white24),
|
||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
child: const Text('Retour', style: TextStyle(color: Colors.white)),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: ElevatedButton(
|
||
onPressed: _onCropConfirm,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF1A73E8),
|
||
foregroundColor: Colors.white,
|
||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInteractiveCrop() {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||
|
||
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
|
||
// visée = toute la zone visible → aucun bord noir autour du cadre.
|
||
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
|
||
|
||
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
||
_initializeImagePosition();
|
||
}
|
||
|
||
return GestureDetector(
|
||
onScaleStart: _onScaleStart,
|
||
onScaleUpdate: _onScaleUpdate,
|
||
child: Stack(
|
||
children: [
|
||
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
|
||
Positioned.fill(
|
||
child: Center(
|
||
child: Transform(
|
||
transform: Matrix4.identity()
|
||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||
..scale(_scale, _scale)
|
||
..rotateZ(_rotation * (math.pi / 180)),
|
||
alignment: Alignment.center,
|
||
child: Image.file(
|
||
File(widget.imagePath),
|
||
fit: BoxFit.cover,
|
||
width: _viewportSize.width,
|
||
height: _viewportSize.height,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 2. Les lignes vertes de visée prolongées
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: Stack(
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: double.infinity,
|
||
height: 1.5,
|
||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||
),
|
||
),
|
||
Center(
|
||
child: Container(
|
||
width: 1.5,
|
||
height: double.infinity,
|
||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: ColorFiltered(
|
||
colorFilter: ColorFilter.mode(
|
||
const Color(0xFF101214),
|
||
BlendMode.srcOut,
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: _cropSize,
|
||
height: _cropSize,
|
||
color: Colors.black,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 4. Ton overlay avec les coins blancs
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: CropOverlay(cropSize: _cropSize, showGrid: false),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _initializeImagePosition() {
|
||
if (_imageSize == null) return;
|
||
|
||
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
|
||
_scale = widget.initialScale ?? 1.0;
|
||
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
||
|
||
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
|
||
if (widget.initialOffset != null) {
|
||
_offset = widget.initialOffset!;
|
||
} else {
|
||
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
|
||
}
|
||
}
|
||
|
||
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
||
// Les symboles « − » et « + » de part et d'autre sont purement décoratifs.
|
||
Widget _buildDirectionalPad() {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_buildCropSignLabel('−'),
|
||
const SizedBox(width: 10),
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)),
|
||
const SizedBox(width: 28),
|
||
_buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)),
|
||
],
|
||
),
|
||
_buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)),
|
||
],
|
||
),
|
||
const SizedBox(width: 10),
|
||
_buildCropSignLabel('+'),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
|
||
return Container(
|
||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)),
|
||
margin: const EdgeInsets.all(2),
|
||
child: IconButton(
|
||
icon: Icon(icon, color: Colors.white, size: 22),
|
||
onPressed: onPressed,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCropSignLabel(String text) {
|
||
return Text(
|
||
text,
|
||
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
|
||
);
|
||
}
|
||
|
||
void _nudge(double dx, double dy) {
|
||
setState(() {
|
||
_offset = _offset + Offset(dx, dy);
|
||
});
|
||
}
|
||
|
||
void _onScaleStart(ScaleStartDetails details) {
|
||
_baseScale = _scale;
|
||
_startFocalPoint = details.focalPoint;
|
||
_startOffset = _offset;
|
||
}
|
||
|
||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||
setState(() {
|
||
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
|
||
final delta = details.focalPoint - _startFocalPoint;
|
||
_offset = _startOffset + delta;
|
||
});
|
||
}
|
||
|
||
Future<void> _onCropConfirm() async {
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
|
||
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
|
||
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
|
||
final double displayPerSourcePx = math.max(
|
||
_viewportSize.width / _imageSize!.width,
|
||
_viewportSize.height / _imageSize!.height,
|
||
);
|
||
|
||
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
|
||
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
|
||
// sont remplis en noir → la position choisie est respectée à l'identique.
|
||
final croppedImagePath = await _cropService.cropViewport(
|
||
sourcePath: widget.imagePath,
|
||
offsetDx: _offset.dx,
|
||
offsetDy: _offset.dy,
|
||
displayPerSourcePx: displayPerSourcePx,
|
||
cropSizeDisplay: _cropSize,
|
||
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
|
||
// il n'agrandit pas le rendu de sortie.
|
||
zoomScale: _scale,
|
||
rotationDegrees: _rotation,
|
||
);
|
||
|
||
const targetCenterX = 0.5;
|
||
const targetCenterY = 0.5;
|
||
|
||
if (!mounted) return;
|
||
|
||
Navigator.pushReplacement(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => AnalysisScreen(
|
||
imagePath: croppedImagePath,
|
||
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
|
||
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
|
||
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
|
||
originalImagePath: widget.imagePath,
|
||
targetType: widget.targetType,
|
||
initialCenterX: targetCenterX,
|
||
initialCenterY: targetCenterY,
|
||
cropScale: 1.0,
|
||
cropOffset: Offset.zero,
|
||
cropRotation: 0.0,
|
||
),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() => _isLoading = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
} |