Retire imports, champs et variables locales non utilises (14 warnings) : - imports inutilises (app, database_helper, impact_editor, target_calibration, session_provider) - champs write-only de capture_screen (_cropService, _scanAnimation, _isCameraInitialized, _alignmentStatus) et _movingShotId - bloc de calcul mort dans crop_screen et variables locales (themeProvider, ny) _filterType (history_screen) volontairement conserve : c'est une fonctionnalite de filtre inachevee, pas du code mort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
9.9 KiB
Dart
283 lines
9.9 KiB
Dart
/// Écran d'édition des impacts — PLEIN ÉCRAN dédié au zoom et au placement.
|
|
///
|
|
/// Cet écran est volontairement minimal : un Scaffold dont le body est
|
|
/// directement un InteractiveViewer (sans SingleScrollView ni AspectRatio
|
|
/// contraint autour). C'est la configuration la plus fiable pour le pinch :
|
|
/// l'InteractiveViewer reçoit les deux doigts sans concurrence avec un
|
|
/// scroll vertical ou une transformation parente.
|
|
///
|
|
/// Interactions :
|
|
/// - Tap sur zone vide -> ajoute un impact
|
|
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
|
|
/// - Appui long + glisser -> déplace l'impact
|
|
///
|
|
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
|
|
/// AnalysisProvider (passé en ChangeNotifierProvider.value côté appelant).
|
|
library;
|
|
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../data/models/shot.dart';
|
|
import 'analysis_provider.dart';
|
|
import 'widgets/target_overlay.dart';
|
|
|
|
class ImpactEditorScreen extends StatefulWidget {
|
|
const ImpactEditorScreen({super.key});
|
|
|
|
@override
|
|
State<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
|
|
}
|
|
|
|
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
|
final TransformationController _transformationController =
|
|
TransformationController();
|
|
final GlobalKey _imageKey = GlobalKey();
|
|
|
|
double _currentZoomScale = 1.0;
|
|
String? _movingShotId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_transformationController.addListener(_onTransformChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_transformationController.removeListener(_onTransformChanged);
|
|
_transformationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onTransformChanged() {
|
|
final scale = _transformationController.value.getMaxScaleOnAxis();
|
|
if (scale != _currentZoomScale) {
|
|
setState(() => _currentZoomScale = scale);
|
|
}
|
|
}
|
|
|
|
/// Convertit une position globale en coordonnées relatives (0..1) sur l'image.
|
|
Offset? _toImageRelative(Offset globalPosition) {
|
|
final RenderBox? box =
|
|
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (box == null) return null;
|
|
final local = box.globalToLocal(globalPosition);
|
|
final relX = (local.dx / box.size.width).clamp(0.0, 1.0);
|
|
final relY = (local.dy / box.size.height).clamp(0.0, 1.0);
|
|
return Offset(relX, relY);
|
|
}
|
|
|
|
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
|
|
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
|
|
{double tolerance = 0.04}) {
|
|
Shot? closest;
|
|
double minDistance = double.infinity;
|
|
for (final shot in provider.shots) {
|
|
final dx = shot.x - rel.dx;
|
|
final dy = shot.y - rel.dy;
|
|
final distance = math.sqrt(dx * dx + dy * dy);
|
|
if (distance < minDistance && distance < tolerance) {
|
|
minDistance = distance;
|
|
closest = shot;
|
|
}
|
|
}
|
|
return closest;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<AnalysisProvider>();
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
title: Text('Placement des impacts (${provider.shotCount})'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
tooltip: 'Retour à la calibration',
|
|
onPressed: () => Navigator.pop(context, false),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text(
|
|
'VALIDER',
|
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// Bandeau d'aide compact
|
|
Container(
|
|
width: double.infinity,
|
|
color: Colors.white10,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: const Text(
|
|
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
|
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
|
|
// Zone image plein écran : InteractiveViewer dans un body nu.
|
|
Expanded(
|
|
child: InteractiveViewer(
|
|
transformationController: _transformationController,
|
|
minScale: 1.0,
|
|
maxScale: 12.0,
|
|
boundaryMargin: const EdgeInsets.all(80),
|
|
panEnabled: _movingShotId == null,
|
|
child: Center(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
// TAP : éditer si on touche un impact, sinon ajouter.
|
|
onTapUp: (details) {
|
|
if (_movingShotId != null) return;
|
|
final rel = _toImageRelative(details.globalPosition);
|
|
if (rel == null) return;
|
|
|
|
final hit = _hitTestShot(provider, rel);
|
|
if (hit != null) {
|
|
_showShotDetails(context, provider, hit);
|
|
} else {
|
|
provider.addShot(rel.dx, rel.dy);
|
|
}
|
|
},
|
|
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
|
onLongPressStart: (details) {
|
|
final rel = _toImageRelative(details.globalPosition);
|
|
if (rel == null) return;
|
|
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
|
|
if (hit != null) {
|
|
setState(() => _movingShotId = hit.id);
|
|
}
|
|
},
|
|
onLongPressMoveUpdate: (details) {
|
|
if (_movingShotId == null) return;
|
|
// Décalage pour que l'impact reste visible au-dessus du doigt.
|
|
final adjusted =
|
|
details.globalPosition + const Offset(-25, -35);
|
|
final rel = _toImageRelative(adjusted);
|
|
if (rel == null) return;
|
|
provider.updateShotPosition(
|
|
_movingShotId!, rel.dx, rel.dy);
|
|
},
|
|
onLongPressEnd: (_) {
|
|
if (_movingShotId != null) {
|
|
setState(() => _movingShotId = null);
|
|
}
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
Image.file(
|
|
File(provider.imagePath!),
|
|
key: _imageKey,
|
|
fit: BoxFit.contain,
|
|
),
|
|
Positioned.fill(
|
|
child: TargetOverlay(
|
|
targetCenterX: provider.targetCenterX,
|
|
targetCenterY: provider.targetCenterY,
|
|
targetRadius: provider.targetRadius,
|
|
targetType: provider.targetType!,
|
|
shots: provider.shots,
|
|
showRings: true,
|
|
zoomScale: _currentZoomScale,
|
|
// L'ajout et la sélection sont gérés par le
|
|
// GestureDetector parent ci-dessus.
|
|
onShotTapped: (shot) =>
|
|
_showShotDetails(context, provider, shot),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showShotDetails(
|
|
BuildContext context,
|
|
AnalysisProvider provider,
|
|
Shot shot,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Impact #${provider.shots.indexOf(shot) + 1}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
),
|
|
Text(
|
|
'ID: ${shot.id}',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(color: Colors.grey, fontSize: 10),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
leading: const Icon(Icons.score),
|
|
title: const Text('Modifier le score'),
|
|
trailing: DropdownButton<int>(
|
|
value: shot.score.clamp(0, 10),
|
|
items: List.generate(11, (index) => index)
|
|
.map(
|
|
(s) => DropdownMenuItem(
|
|
value: s,
|
|
child: Text(
|
|
'$s',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (newScore) {
|
|
if (newScore != null) {
|
|
provider.updateShotScore(shot.id, newScore);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
provider.removeShot(shot.id);
|
|
Navigator.pop(context);
|
|
},
|
|
icon: const Icon(Icons.delete, color: Colors.red),
|
|
label: const Text(
|
|
'SUPPRIMER',
|
|
style: TextStyle(color: Colors.red),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |