Files
impact/lib/features/crop/crop_screen.dart
qguillaume cec9838e9b refactor: perf getAllSessions, zéro warning analyze, docs à jour
- getAllSessions : élimine le N+1 (une requête par session + par analyse)
  au profit de 3 requêtes groupées (sessions, analyses IN, shots IN),
  découpées en paquets de 500 pour rester sous la limite SQLite. Assemblage
  en mémoire via maps — accueil/historique/stats ne ralentiront plus au fil
  des mois
- flutter analyze : 28 -> 0 problème. APIs dépréciées migrées (withOpacity
  -> withValues, scale -> scaleByDouble, DropdownButtonFormField.value ->
  initialValue, dialogBackgroundColor -> dialogTheme, activeColor ->
  activeThumbColor), use_build_context_synchronously corrigés dans
  l'armurerie (repository capturé avant await + gardes mounted), print ->
  debugPrint, identifiants TopLeft/... -> lowerCamelCase, blocs if, champ final
- _showShotDetails dédupliqué : bottom sheet extraite dans le widget partagé
  shot_details_sheet.dart, utilisée par l'écran d'analyse et l'éditeur d'impacts
- CLAUDE.md : sections Features réécrites (retrait détection auto/références,
  ajout capture/plotting manuel) ; description pubspec renseignée

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:40:25 +02:00

493 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.withValues(alpha: 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.withValues(alpha: 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)
..scaleByDouble(_scale, _scale, _scale, 1.0)
..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).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withValues(alpha: 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),
);
}
}
}
}