Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a102bfd5ef | ||
|
|
89b4f433b6 | ||
|
|
9fa3a6f46d | ||
|
|
8165d3bab3 | ||
|
|
ba9975f047 | ||
|
|
f7fecf0ef2 | ||
|
|
0564bdbb48 | ||
|
|
05356aaaea | ||
|
|
c4880ccb68 | ||
|
|
00ae0117c5 | ||
|
|
1d8124c8d8 | ||
|
|
600a6bef30 | ||
|
|
5d2e612b9c | ||
|
|
4e26431be5 | ||
|
|
559369c84d | ||
|
|
653aa5d5b0 | ||
|
|
fec33a327a | ||
|
|
8e64839fc6 | ||
|
|
331fda0ffb | ||
|
|
064f5c1fe3 | ||
|
|
b4226216eb | ||
|
|
2107f187b6 | ||
|
|
0c509bfdc3 | ||
|
|
71ad670ad8 | ||
|
|
625810234e | ||
|
|
9afe3c8508 |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(flutter analyze:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(flutter clean:*)",
|
||||
"Bash(flutter pub get:*)",
|
||||
"Bash(flutter run:*)",
|
||||
"Bash(cmake:*)",
|
||||
"Bash(where:*)",
|
||||
"Bash(winget search:*)",
|
||||
"Bash(winget install:*)",
|
||||
"Bash(\"/c/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vs_installer.exe\" modify --installPath \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait)",
|
||||
"Bash(cmd //c \"\"\"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\18\\\\Community\\\\Common7\\\\Tools\\\\VsDevCmd.bat\"\" && flutter run -d windows\")",
|
||||
"Bash(flutter doctor:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
.claude/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
||||
import 'core/theme/theme_provider.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'main_navigation_holder.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
class BullyApp extends StatelessWidget {
|
||||
|
||||
76
lib/core/widgets/metric_info_button.dart
Normal file
76
lib/core/widgets/metric_info_button.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
/// Bouton d'information (ⓘ) qui explique des métriques à l'utilisateur.
|
||||
///
|
||||
/// Affiche une petite icône cliquable ; au clic, une boîte de dialogue
|
||||
/// détaille la signification de chaque statistique de la carte associée.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Explication d'une métrique : un libellé et sa description.
|
||||
class MetricExplanation {
|
||||
final String label;
|
||||
final String description;
|
||||
|
||||
const MetricExplanation(this.label, this.description);
|
||||
}
|
||||
|
||||
class MetricInfoButton extends StatelessWidget {
|
||||
final String title;
|
||||
final List<MetricExplanation> explanations;
|
||||
|
||||
const MetricInfoButton({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.explanations,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.info_outline, size: 18, color: Colors.grey[500]),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'À quoi ça correspond ?',
|
||||
onPressed: () => _showInfo(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _showInfo(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final e in explanations)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.label,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(e.description),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import '../models/session.dart';
|
||||
@@ -11,6 +10,12 @@ import '../../core/constants/app_constants.dart';
|
||||
class DatabaseHelper {
|
||||
static DatabaseHelper? _instance;
|
||||
static Database? _database;
|
||||
// On met en cache le Future d'initialisation (et non la Database résolue)
|
||||
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
|
||||
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
|
||||
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
|
||||
// toute première écriture peu fiable.
|
||||
static Future<Database>? _initFuture;
|
||||
|
||||
DatabaseHelper._internal();
|
||||
|
||||
@@ -20,7 +25,9 @@ class DatabaseHelper {
|
||||
}
|
||||
|
||||
Future<Database> get database async {
|
||||
_database ??= await _initDatabase();
|
||||
if (_database != null) return _database!;
|
||||
_initFuture ??= _initDatabase();
|
||||
_database = await _initFuture!;
|
||||
return _database!;
|
||||
}
|
||||
|
||||
@@ -510,6 +517,16 @@ class DatabaseHelper {
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('''
|
||||
SELECT COUNT(id) as count
|
||||
FROM ${AppConstants.sessionsTable}
|
||||
WHERE weapon_id = ?
|
||||
''', [weaponId]);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
||||
final db = await database;
|
||||
return await db.insert(
|
||||
@@ -543,5 +560,6 @@ class DatabaseHelper {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
_initFuture = null;
|
||||
}
|
||||
}
|
||||
@@ -182,18 +182,23 @@ class SessionRepository {
|
||||
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||
}
|
||||
|
||||
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||
return await _databaseHelper.getSessionCountForWeapon(weaponId);
|
||||
}
|
||||
|
||||
Future<void> addMaintenanceEntry({
|
||||
required String weaponId,
|
||||
required MaintenanceType type,
|
||||
required String description,
|
||||
int? roundsSinceLast,
|
||||
DateTime? date,
|
||||
}) async {
|
||||
final entry = MaintenanceEntry(
|
||||
id: _uuid.v4(),
|
||||
weaponId: weaponId,
|
||||
type: type,
|
||||
description: description,
|
||||
date: DateTime.now(),
|
||||
date: date ?? DateTime.now(),
|
||||
roundsSinceLastMaintenance: roundsSinceLast,
|
||||
);
|
||||
await _databaseHelper.insertMaintenance(entry);
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../services/score_calculator_service.dart';
|
||||
import '../../services/grouping_analyzer_service.dart';
|
||||
import '../session/session_provider.dart';
|
||||
import 'analysis_provider.dart';
|
||||
import 'impact_editor_screen.dart';
|
||||
import '../crop/crop_screen.dart';
|
||||
import '../capture/capture_screen.dart';
|
||||
import 'widgets/target_overlay.dart';
|
||||
@@ -28,6 +29,7 @@ import 'widgets/grouping_stats.dart';
|
||||
|
||||
class AnalysisScreen extends StatelessWidget {
|
||||
final String imagePath;
|
||||
final String? originalImagePath; // AJOUT : image source avant le crop à 85%
|
||||
final TargetType targetType;
|
||||
final double? initialCenterX;
|
||||
final double? initialCenterY;
|
||||
@@ -38,6 +40,7 @@ class AnalysisScreen extends StatelessWidget {
|
||||
const AnalysisScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.originalImagePath, // AJOUT
|
||||
required this.targetType,
|
||||
this.initialCenterX,
|
||||
this.initialCenterY,
|
||||
@@ -75,6 +78,7 @@ class AnalysisScreen extends StatelessWidget {
|
||||
return p;
|
||||
},
|
||||
child: _AnalysisScreenContent(
|
||||
originalImagePath: originalImagePath, // AJOUT
|
||||
cropScale: cropScale,
|
||||
cropOffset: cropOffset,
|
||||
cropRotation: cropRotation, // Envoyé à la structure d'affichage
|
||||
@@ -84,11 +88,13 @@ class AnalysisScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AnalysisScreenContent extends StatefulWidget {
|
||||
final String? originalImagePath; // AJOUT
|
||||
final double? cropScale;
|
||||
final Offset? cropOffset;
|
||||
final double? cropRotation;
|
||||
|
||||
const _AnalysisScreenContent({
|
||||
this.originalImagePath, // AJOUT
|
||||
this.cropScale,
|
||||
this.cropOffset,
|
||||
this.cropRotation,
|
||||
@@ -112,7 +118,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
TransformationController();
|
||||
final GlobalKey _imageKey = GlobalKey();
|
||||
double _currentZoomScale = 1.0;
|
||||
String? _movingShotId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -151,6 +156,58 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
|
||||
///
|
||||
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
|
||||
/// persiste dans le TransformationController et se réapplique au retour,
|
||||
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
|
||||
/// d'une transformation identité (zoom 1.0).
|
||||
void _enterCalibration() {
|
||||
_transformationController.value = Matrix4.identity();
|
||||
_currentZoomScale = 1.0;
|
||||
setState(() => _isCalibrating = true);
|
||||
}
|
||||
|
||||
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
|
||||
///
|
||||
/// On utilise ChangeNotifierProvider.value pour que l'éditeur lise et modifie
|
||||
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
|
||||
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
|
||||
///
|
||||
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
|
||||
/// Plotting (lecture seule) ; sinon on repasse en calibration.
|
||||
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
||||
final validated = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
||||
value: provider,
|
||||
child: const ImpactEditorScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (validated == true) {
|
||||
setState(() {
|
||||
_isCalibrating = false;
|
||||
_isSelectingReferences = false;
|
||||
});
|
||||
} else {
|
||||
_enterCalibration();
|
||||
}
|
||||
}
|
||||
|
||||
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
||||
///
|
||||
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
|
||||
/// Repartir de l'image déjà croppée à 85% provoquait un rognage cumulatif
|
||||
/// (0.85 x 0.85 x ...) qui re-zoomait la photo à chaque aller-retour
|
||||
/// entre la capture/crop et la calibration.
|
||||
String _backCropImagePath(AnalysisProvider provider) {
|
||||
return widget.originalImagePath ?? provider.imagePath!;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<AnalysisProvider>();
|
||||
@@ -176,7 +233,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CropScreen(
|
||||
imagePath: provider.imagePath!,
|
||||
// CORRECTION : on repart de l'image SOURCE (non rognée) pour
|
||||
// éviter le rognage cumulatif à 85% qui re-zoomait à chaque retour.
|
||||
imagePath: _backCropImagePath(provider),
|
||||
targetType: provider.targetType!,
|
||||
initialScale: widget.cropScale,
|
||||
initialOffset: widget.cropOffset,
|
||||
@@ -184,7 +243,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setState(() => _isCalibrating = true);
|
||||
// Retour Plotting -> Calibration : on réinitialise le zoom.
|
||||
_enterCalibration();
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -199,9 +259,16 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
if (_isCalibrating)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isCalibrating = false),
|
||||
onPressed: () {
|
||||
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
|
||||
// puis on passe sur l'écran d'édition d'impacts plein écran
|
||||
// (zoom fiable + placement). Le mode Plotting (lecture seule)
|
||||
// s'affichera au retour si l'utilisateur valide.
|
||||
_calibrationKey.currentState?.commitCalibration();
|
||||
_openImpactEditor(context.read<AnalysisProvider>());
|
||||
},
|
||||
child: const Text(
|
||||
'TERMINER',
|
||||
'VALIDER',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -293,7 +360,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildZoomableImageWithOverlay(context, provider),
|
||||
: _buildReadOnlyPlotImage(context, provider),
|
||||
),
|
||||
|
||||
if (!_isCalibrating)
|
||||
@@ -301,6 +368,27 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
Card(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.18),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.edit_location_alt,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
title: const Text('Modifier les impacts'),
|
||||
subtitle: const Text(
|
||||
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.open_in_full,
|
||||
size: 16,
|
||||
),
|
||||
// Rouvre l'éditeur plein écran en partageant le provider.
|
||||
onTap: () =>
|
||||
_openImpactEditor(context.read<AnalysisProvider>()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
child: ListTile(
|
||||
@@ -316,7 +404,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
),
|
||||
onTap: () => setState(() => _isCalibrating = true),
|
||||
// Retour vers la calibration : on réinitialise le zoom.
|
||||
onTap: () => _enterCalibration(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -469,7 +558,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildZoomableImageWithOverlay(
|
||||
/// Affichage du plotting en LECTURE SEULE.
|
||||
///
|
||||
/// L'édition (ajout / déplacement / suppression) se fait désormais
|
||||
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
|
||||
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
|
||||
/// Le tap sur un impact ouvre simplement ses détails.
|
||||
Widget _buildReadOnlyPlotImage(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
) {
|
||||
@@ -477,126 +572,30 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
transformationController: _transformationController,
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
panEnabled: _movingShotId == null,
|
||||
child: Transform(
|
||||
transform: Matrix4.identity()
|
||||
..setTranslationRaw(
|
||||
widget.cropOffset?.dx ?? 0.0,
|
||||
widget.cropOffset?.dy ?? 0.0,
|
||||
0.0,
|
||||
)
|
||||
..scale(1.0, 1.0)
|
||||
..rotateZ((provider.cropRotation) * (math.pi / 180)),
|
||||
alignment: Alignment.center,
|
||||
child: GestureDetector(
|
||||
onDoubleTapDown: (TapDownDetails details) {
|
||||
final RenderBox? box =
|
||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
final localOffset = box.globalToLocal(details.globalPosition);
|
||||
final relX = localOffset.dx / box.size.width;
|
||||
final relY = localOffset.dy / box.size.height;
|
||||
|
||||
if (provider.shots.isEmpty) return;
|
||||
|
||||
Shot? closestShot;
|
||||
double minDistance = double.infinity;
|
||||
const double clickTolerance = 0.05;
|
||||
|
||||
for (final shot in provider.shots) {
|
||||
final dx = shot.x - relX;
|
||||
final dy = shot.y - relY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < minDistance && distance < clickTolerance) {
|
||||
minDistance = distance;
|
||||
closestShot = shot;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestShot != null) {
|
||||
_showShotDetails(context, provider, closestShot);
|
||||
}
|
||||
},
|
||||
onLongPressStart: (LongPressStartDetails details) {
|
||||
final RenderBox? box =
|
||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
final localOffset = box.globalToLocal(details.globalPosition);
|
||||
final relX = localOffset.dx / box.size.width;
|
||||
final relY = localOffset.dy / box.size.height;
|
||||
|
||||
if (provider.shots.isEmpty) return;
|
||||
|
||||
Shot? closestShot;
|
||||
double minDistance = double.infinity;
|
||||
const double dragTolerance = 0.06;
|
||||
|
||||
for (final shot in provider.shots) {
|
||||
final dx = shot.x - relX;
|
||||
final dy = shot.y - relY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < minDistance && distance < dragTolerance) {
|
||||
minDistance = distance;
|
||||
closestShot = shot;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestShot != null) {
|
||||
setState(() {
|
||||
_movingShotId = closestShot!.id;
|
||||
});
|
||||
}
|
||||
},
|
||||
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
|
||||
if (_movingShotId == null) return;
|
||||
|
||||
final RenderBox? box =
|
||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
final adjustedGlobalPosition =
|
||||
details.globalPosition + const Offset(-25, -35);
|
||||
final localOffset = box.globalToLocal(adjustedGlobalPosition);
|
||||
|
||||
final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0);
|
||||
final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0);
|
||||
|
||||
provider.updateShotPosition(_movingShotId!, relX, relY);
|
||||
},
|
||||
onLongPressEnd: (_) {
|
||||
if (_movingShotId != null) {
|
||||
setState(() {
|
||||
_movingShotId = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.file(
|
||||
File(provider.imagePath!),
|
||||
key: _imageKey,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
TargetOverlay(
|
||||
targetCenterX: provider.targetCenterX,
|
||||
targetCenterY: provider.targetCenterY,
|
||||
targetRadius: provider.targetRadius,
|
||||
targetType: provider.targetType!,
|
||||
shots: provider.shots,
|
||||
showRings: true,
|
||||
zoomScale: _currentZoomScale,
|
||||
onShotTapped: (shot) =>
|
||||
_showShotDetails(context, provider, shot),
|
||||
onAddShot: (relX, relY) => provider.addShot(relX, relY),
|
||||
),
|
||||
],
|
||||
boundaryMargin: const EdgeInsets.all(80),
|
||||
panEnabled: true,
|
||||
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,
|
||||
// Lecture seule : tap sur impact -> détails (consultation).
|
||||
onShotTapped: (shot) =>
|
||||
_showShotDetails(context, provider, shot),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -698,7 +697,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
final path = provider.imagePath!;
|
||||
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
|
||||
final path = _backCropImagePath(provider);
|
||||
final type = provider.targetType!;
|
||||
|
||||
Navigator.pushReplacement(
|
||||
|
||||
283
lib/features/analysis/impact_editor_screen.dart
Normal file
283
lib/features/analysis/impact_editor_screen.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
/// É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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/metric_info_button.dart';
|
||||
import '../../../services/grouping_analyzer_service.dart';
|
||||
|
||||
class GroupingStats extends StatelessWidget {
|
||||
@@ -43,31 +44,64 @@ class GroupingStats extends StatelessWidget {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const MetricInfoButton(
|
||||
title: 'Groupement',
|
||||
explanations: [
|
||||
MetricExplanation(
|
||||
'Étalement',
|
||||
'Distance entre vos deux impacts les plus éloignés, '
|
||||
'exprimée en % de la largeur de l\'image. Plus c\'est '
|
||||
'bas, plus le groupement est serré.',
|
||||
),
|
||||
MetricExplanation(
|
||||
'Dispersion',
|
||||
'Régularité des impacts autour de leur centre commun '
|
||||
'(écart-type). Plus c\'est bas, plus vos tirs sont '
|
||||
'réguliers.',
|
||||
),
|
||||
MetricExplanation(
|
||||
'Décalage',
|
||||
'Direction du centre de votre groupement par rapport au '
|
||||
'centre de la cible (ex. « Droite » = vos tirs sont '
|
||||
'globalement décalés vers la droite).',
|
||||
),
|
||||
MetricExplanation(
|
||||
'Étoiles',
|
||||
'Qualité globale du groupement, de ★ (à améliorer) à '
|
||||
'★★★★★ (excellent).',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
_buildQualityBadge(context),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStat(
|
||||
context,
|
||||
'Diametre',
|
||||
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
||||
icon: Icons.straighten,
|
||||
Expanded(
|
||||
child: _buildStat(
|
||||
context,
|
||||
'Étalement',
|
||||
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
||||
icon: Icons.straighten,
|
||||
),
|
||||
),
|
||||
_buildStat(
|
||||
context,
|
||||
'Dispersion',
|
||||
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
||||
icon: Icons.scatter_plot,
|
||||
Expanded(
|
||||
child: _buildStat(
|
||||
context,
|
||||
'Dispersion',
|
||||
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
||||
icon: Icons.scatter_plot,
|
||||
),
|
||||
),
|
||||
_buildStat(
|
||||
context,
|
||||
'Decalage',
|
||||
offsetDescription,
|
||||
icon: Icons.compare_arrows,
|
||||
Expanded(
|
||||
child: _buildStat(
|
||||
context,
|
||||
'Décalage',
|
||||
offsetDescription,
|
||||
icon: Icons.compare_arrows,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
|
||||
|
||||
String _getOffsetDescription(double offsetX, double offsetY) {
|
||||
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
||||
return 'Centre';
|
||||
return 'Centré';
|
||||
}
|
||||
|
||||
String vertical = '';
|
||||
String horizontal = '';
|
||||
|
||||
if (offsetY < -0.02) {
|
||||
vertical = 'H';
|
||||
vertical = 'Haut';
|
||||
} else if (offsetY > 0.02) {
|
||||
vertical = 'B';
|
||||
vertical = 'Bas';
|
||||
}
|
||||
|
||||
if (offsetX < -0.02) {
|
||||
horizontal = 'G';
|
||||
horizontal = 'Gauche';
|
||||
} else if (offsetX > 0.02) {
|
||||
horizontal = 'D';
|
||||
horizontal = 'Droite';
|
||||
}
|
||||
|
||||
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
||||
|
||||
@@ -7,6 +7,7 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/metric_info_button.dart';
|
||||
import '../../../data/models/target_type.dart';
|
||||
import '../../../services/score_calculator_service.dart';
|
||||
|
||||
@@ -44,6 +45,31 @@ class ScoreCard extends StatelessWidget {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
MetricInfoButton(
|
||||
title: 'Score',
|
||||
explanations: [
|
||||
MetricExplanation(
|
||||
'Total',
|
||||
'Somme des points de tous vos impacts, sur le maximum '
|
||||
'possible (nombre d\'impacts × $maxScore points).',
|
||||
),
|
||||
const MetricExplanation(
|
||||
'Impacts',
|
||||
'Nombre de tirs détectés sur la cible.',
|
||||
),
|
||||
MetricExplanation(
|
||||
'Moyenne',
|
||||
'Points marqués en moyenne par impact, sur $maxScore.',
|
||||
),
|
||||
const MetricExplanation(
|
||||
'Réussite',
|
||||
'Votre score exprimé en pourcentage du score maximum '
|
||||
'possible. C\'est une mesure du résultat, pas de la '
|
||||
'régularité des tirs.',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
@@ -67,11 +93,12 @@ class ScoreCard extends StatelessWidget {
|
||||
shotCount > 0
|
||||
? (totalScore / shotCount).toStringAsFixed(1)
|
||||
: '-',
|
||||
subtitle: '/ $maxScore',
|
||||
),
|
||||
if (scoreResult != null)
|
||||
_buildScoreStat(
|
||||
context,
|
||||
'Pourcentage',
|
||||
'Réussite',
|
||||
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
/// Les anneaux sont répartis proportionnellement.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../data/models/target_type.dart';
|
||||
@@ -83,6 +82,16 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
_initRingRadii();
|
||||
}
|
||||
|
||||
/// Fige et propage la calibration courante vers le provider.
|
||||
///
|
||||
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
|
||||
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
|
||||
/// correspond exactement au dernier réglage validé, sans dépendre d'un
|
||||
/// éventuel rebuild intermédiaire.
|
||||
void commitCalibration() {
|
||||
_notifyChange();
|
||||
}
|
||||
|
||||
void _initRingRadii({bool forceRecalculate = false}) {
|
||||
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
||||
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
||||
@@ -328,24 +337,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||
Row(
|
||||
_buildSignLabel('−'),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
||||
const SizedBox(width: 40),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
||||
const SizedBox(width: 40),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
||||
],
|
||||
),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
||||
],
|
||||
),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
||||
const SizedBox(width: 12),
|
||||
_buildSignLabel('+'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Symbole purement décoratif affiché de part et d'autre de la croix.
|
||||
Widget _buildSignLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/// Overlay visuel de la cible.
|
||||
///
|
||||
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
|
||||
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout
|
||||
/// d'impacts et la sélection d'impacts existants.
|
||||
/// et les impacts de référence. Gère uniquement la SÉLECTION d'impacts existants
|
||||
/// (tap sur un impact). L'AJOUT d'un impact est délégué à l'écran parent pour
|
||||
/// éviter tout conflit de gestes avec le zoom/pan de l'InteractiveViewer.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -48,76 +49,69 @@ class TargetOverlay extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (details) {
|
||||
if (onAddShot != null) {
|
||||
// Utiliser les constraints pour un calcul précis
|
||||
final relX = details.localPosition.dx / constraints.maxWidth;
|
||||
final relY = details.localPosition.dy / constraints.maxHeight;
|
||||
onAddShot!(relX, relY);
|
||||
}
|
||||
},
|
||||
child: CustomPaint(
|
||||
painter: _TargetOverlayPainter(
|
||||
shots: shots,
|
||||
targetCenterX: targetCenterX,
|
||||
targetCenterY: targetCenterY,
|
||||
targetRadius: targetRadius,
|
||||
targetType: targetType,
|
||||
ringCount: ringCount,
|
||||
ringRadii: ringRadii,
|
||||
groupingCenterX: groupingCenterX,
|
||||
groupingCenterY: groupingCenterY,
|
||||
groupingDiameter: groupingDiameter,
|
||||
referenceImpacts: referenceImpacts,
|
||||
zoomScale: zoomScale,
|
||||
showRings: showRings,
|
||||
),
|
||||
child: Stack(
|
||||
// IMPORTANT : plus de GestureDetector global ici.
|
||||
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
|
||||
// HitTestBehavior.translucent) volait les pointeurs au pinch de
|
||||
// l'InteractiveViewer parent et rendait le zoom capricieux.
|
||||
//
|
||||
// Désormais :
|
||||
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
||||
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
||||
// zones de tap localisées (deferToChild) placées sur chaque impact.
|
||||
return IgnorePointer(
|
||||
ignoring: false,
|
||||
child: CustomPaint(
|
||||
painter: _TargetOverlayPainter(
|
||||
shots: shots,
|
||||
targetCenterX: targetCenterX,
|
||||
targetCenterY: targetCenterY,
|
||||
targetRadius: targetRadius,
|
||||
targetType: targetType,
|
||||
ringCount: ringCount,
|
||||
ringRadii: ringRadii,
|
||||
groupingCenterX: groupingCenterX,
|
||||
groupingCenterY: groupingCenterY,
|
||||
groupingDiameter: groupingDiameter,
|
||||
referenceImpacts: referenceImpacts,
|
||||
zoomScale: zoomScale,
|
||||
showRings: showRings,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: shots.map((shot) {
|
||||
final x = shot.x * constraints.maxWidth;
|
||||
final y = shot.y * constraints.maxHeight;
|
||||
// Zone de tap qui reste constante à l'écran malgré le zoom.
|
||||
final tapSize = 30 / zoomScale;
|
||||
final halfTapSize = tapSize / 2;
|
||||
return Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, innerConstraints) {
|
||||
final x = shot.x * innerConstraints.maxWidth;
|
||||
final y = shot.y * innerConstraints.maxHeight;
|
||||
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
|
||||
final tapSize = 30 / zoomScale;
|
||||
final halfTapSize = tapSize / 2;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: x - halfTapSize,
|
||||
top: y - halfTapSize,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onShotTapped?.call(shot),
|
||||
child: Container(
|
||||
width: tapSize,
|
||||
height: tapSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
left: x - halfTapSize,
|
||||
top: y - halfTapSize,
|
||||
child: GestureDetector(
|
||||
// deferToChild : ne capte le toucher QUE sur la zone du
|
||||
// Container (un cercle opaque au hit-test), pas ailleurs.
|
||||
// Le reste de la surface reste donc disponible pour le
|
||||
// pinch/pan de l'InteractiveViewer.
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTap: () => onShotTapped?.call(shot),
|
||||
child: Container(
|
||||
width: tapSize,
|
||||
height: tapSize,
|
||||
decoration: const BoxDecoration(
|
||||
// Opaque pour le hit-test (couleur transparente visuellement
|
||||
// mais non nulle), pour que le tap soit bien capté ici.
|
||||
color: Color(0x01000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -211,8 +205,8 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
final prevMultiplier = i == 0
|
||||
? 0.0
|
||||
: (ringRadii != null && ringRadii!.length == ringCount)
|
||||
? ringRadii![i - 1]
|
||||
: i / ringCount;
|
||||
? ringRadii![i - 1]
|
||||
: i / ringCount;
|
||||
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
|
||||
final score = 10 - i;
|
||||
|
||||
@@ -292,16 +286,17 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
final strokeWidth = 3 / zoomScale;
|
||||
final fontSize = 10 / zoomScale;
|
||||
|
||||
// Draw outer circle (white outline for visibility)
|
||||
// Draw outer circle (white outline for visibility) — gardé OPAQUE pour
|
||||
// bien repérer le centre même quand le remplissage est transparent.
|
||||
final outlinePaint = Paint()
|
||||
..color = AppTheme.impactOutlineColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
||||
|
||||
// Draw impact marker
|
||||
// Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
|
||||
final impactPaint = Paint()
|
||||
..color = AppTheme.impactColor
|
||||
..color = AppTheme.impactColor.withValues(alpha: 0.3)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -14,13 +16,77 @@ import '../../data/models/target_type.dart';
|
||||
import '../crop/crop_screen.dart';
|
||||
import '../session/session_provider.dart';
|
||||
import 'widgets/image_source_button.dart';
|
||||
import '../../services/image_crop_service.dart';
|
||||
import '../../services/opencv_target_service.dart';
|
||||
import '../../services/parallelism_service.dart'; // NOUVEAU
|
||||
import '../../services/target_rectify_service.dart'; // NOUVEAU : redressement
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Paramètres sérialisables pour convertir une frame caméra dans un Isolate.
|
||||
class _FrameConvertParams {
|
||||
final int width;
|
||||
final int height;
|
||||
final Uint8List yBytes;
|
||||
final Uint8List uBytes;
|
||||
final Uint8List vBytes;
|
||||
final int yRowStride;
|
||||
final int uvRowStride;
|
||||
final int uvPixelStride;
|
||||
final String outputPath;
|
||||
|
||||
_FrameConvertParams({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.yBytes,
|
||||
required this.uBytes,
|
||||
required this.vBytes,
|
||||
required this.yRowStride,
|
||||
required this.uvRowStride,
|
||||
required this.uvPixelStride,
|
||||
required this.outputPath,
|
||||
});
|
||||
}
|
||||
|
||||
/// Conversion YUV420 → RGB (sous-échantillonnée /2) + encodage JPEG, exécutée
|
||||
/// dans un Isolate pour ne JAMAIS bloquer le thread UI pendant l'aperçu caméra
|
||||
/// (c'était la cause principale du lag à la prise de photo). Retourne true si
|
||||
/// le fichier d'analyse a bien été écrit.
|
||||
bool _convertAndEncodeFrameIsolate(_FrameConvertParams p) {
|
||||
try {
|
||||
final int targetW = p.width ~/ 2;
|
||||
final int targetH = p.height ~/ 2;
|
||||
final image = img.Image(width: targetW, height: targetH);
|
||||
|
||||
for (int y = 0; y < targetH; y++) {
|
||||
for (int x = 0; x < targetW; x++) {
|
||||
final int srcX = x * 2;
|
||||
final int srcY = y * 2;
|
||||
|
||||
final int yIndex = srcY * p.yRowStride + srcX;
|
||||
final int uvIndex =
|
||||
(srcY ~/ 2) * p.uvRowStride + (srcX ~/ 2) * p.uvPixelStride;
|
||||
|
||||
final int yVal = p.yBytes[yIndex];
|
||||
final int uVal = p.uBytes[uvIndex] - 128;
|
||||
final int vVal = p.vBytes[uvIndex] - 128;
|
||||
|
||||
final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt();
|
||||
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal)
|
||||
.clamp(0, 255)
|
||||
.toInt();
|
||||
final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
|
||||
|
||||
image.setPixelRgb(x, y, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
// PERF : qualité 50 pour la frame d'analyse temps réel (jetable).
|
||||
File(p.outputPath).writeAsBytesSync(img.encodeJpg(image, quality: 50));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CaptureScreen extends StatefulWidget {
|
||||
const CaptureScreen({super.key});
|
||||
|
||||
@@ -31,31 +97,36 @@ class CaptureScreen extends StatefulWidget {
|
||||
class _CaptureScreenState extends State<CaptureScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
final ImageCropService _cropService = ImageCropService();
|
||||
final TargetType _selectedType = TargetType.concentric;
|
||||
final OpenCVTargetService _opencvService = OpenCVTargetService();
|
||||
|
||||
// NOUVEAU : Service IMU de parallélisme
|
||||
// L'angle passe à l'ORANGE dès 3° d'inclinaison (pitch ou roll).
|
||||
// Petite hystérésis : il faut revenir sous 2° pour repasser au vert,
|
||||
// ce qui évite le clignotement quand la main tremble autour de 3°.
|
||||
final ParallelismService _parallelismService = ParallelismService(
|
||||
alignThreshold: 25.0, // Seuil pour passer au vert (permissif)
|
||||
misalignThreshold: 32.0, // Seuil pour repasser à l'orange
|
||||
alignThreshold: 2.0, // retour au vert : worstAngle ≤ 2°
|
||||
misalignThreshold: 3.0, // passage à l'orange : worstAngle > 3°
|
||||
);
|
||||
|
||||
String? _selectedImagePath;
|
||||
bool _isLoading = false;
|
||||
bool _isCapturing = false; // garde anti-double-capture pendant le traitement
|
||||
// Chemin de la photo tout juste capturée, affichée FIGÉE pendant le
|
||||
// traitement (downscale). Tant qu'il est null, la capture physique est
|
||||
// encore en cours → l'utilisateur doit rester immobile. Une fois rempli,
|
||||
// la photo est prise et l'utilisateur peut bouger.
|
||||
String? _capturedPreviewPath;
|
||||
|
||||
// Caméra live
|
||||
CameraController? _cameraController;
|
||||
List<CameraDescription>? _cameras;
|
||||
bool _isCameraInitialized = false;
|
||||
bool _showLiveCamera = false;
|
||||
|
||||
// Animation pulsation du viseur
|
||||
late AnimationController _scanAnimationController;
|
||||
late Animation<double> _scanAnimation;
|
||||
|
||||
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
|
||||
bool? _alignmentStatus;
|
||||
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
|
||||
Timer? _detectionTimer;
|
||||
bool _isAnalyzingFrame = false;
|
||||
@@ -64,9 +135,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
ParallelismData? _parallelismData;
|
||||
StreamSubscription<ParallelismData>? _parallelismSubscription;
|
||||
|
||||
// Service de redressement de cible (warp perspective)
|
||||
final TargetRectifyService _rectifyService = TargetRectifyService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -74,10 +142,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
duration: const Duration(seconds: 2),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -98,10 +162,23 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
_parallelismService.start();
|
||||
|
||||
_parallelismSubscription = _parallelismService.stream.listen((data) {
|
||||
if (mounted && _showLiveCamera) {
|
||||
setState(() {
|
||||
_parallelismData = data;
|
||||
});
|
||||
if (!mounted || !_showLiveCamera) return;
|
||||
|
||||
// Le capteur émet à ~20 Hz, mais l'affichage ne dépend que du statut
|
||||
// (couleur verte/orange) et des angles arrondis à 0,1° (message). On ne
|
||||
// reconstruit donc QUE quand l'un d'eux change réellement → bien moins de
|
||||
// rebuilds inutiles de la vue caméra.
|
||||
final prev = _parallelismData;
|
||||
final bool changed = prev == null ||
|
||||
prev.status != data.status ||
|
||||
prev.pitchDegrees.toStringAsFixed(1) !=
|
||||
data.pitchDegrees.toStringAsFixed(1) ||
|
||||
prev.rollDegrees.toStringAsFixed(1) !=
|
||||
data.rollDegrees.toStringAsFixed(1);
|
||||
|
||||
_parallelismData = data;
|
||||
if (changed) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -184,14 +261,19 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
if (_cameraController != null) {
|
||||
await _cameraController!.dispose();
|
||||
_cameraController = null;
|
||||
_isCameraInitialized = false;
|
||||
}
|
||||
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras != null && _cameras!.isNotEmpty) {
|
||||
// PERF : on capture en `veryHigh` (2160p) et non en `max`. Le pipeline
|
||||
// redimensionne ensuite tout à 1080 px max → la résolution `max`
|
||||
// (12–48 MP) ne servait à rien et rallongeait fortement le décodage
|
||||
// post-capture (« délai de quelques secondes »). `veryHigh` reste très
|
||||
// au-dessus du 1080 px final : aucune perte de qualité, mais décodage
|
||||
// bien plus rapide.
|
||||
_cameraController = CameraController(
|
||||
_cameras![0],
|
||||
ResolutionPreset.max,
|
||||
ResolutionPreset.veryHigh,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.jpeg,
|
||||
);
|
||||
@@ -199,9 +281,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
await _cameraController!.initialize();
|
||||
|
||||
setState(() {
|
||||
_isCameraInitialized = true;
|
||||
_showLiveCamera = true;
|
||||
_alignmentStatus = null;
|
||||
_targetResult = null; // reset détection cible
|
||||
_parallelismData = null; // NOUVEAU : reset IMU
|
||||
});
|
||||
@@ -223,29 +303,44 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
_detectionTimer?.cancel();
|
||||
_detectionTimer = null;
|
||||
|
||||
DateTime? _lastAnalysis;
|
||||
DateTime? lastAnalysis;
|
||||
|
||||
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
||||
if (_isAnalyzingFrame) return;
|
||||
final now = DateTime.now();
|
||||
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
|
||||
if (_lastAnalysis != null &&
|
||||
now.difference(_lastAnalysis!).inMilliseconds < 1000) return;
|
||||
_lastAnalysis = now;
|
||||
if (lastAnalysis != null &&
|
||||
now.difference(lastAnalysis!).inMilliseconds < 1000) return;
|
||||
lastAnalysis = now;
|
||||
_isAnalyzingFrame = true;
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = '${tempDir.path}/frame_analysis.jpg';
|
||||
|
||||
final img.Image? converted = _convertCameraImage(cameraImage);
|
||||
if (converted == null) {
|
||||
// On copie les octets des plans MAINTENANT (le buffer caméra est
|
||||
// recyclé dès la sortie du callback), puis on délègue TOUTE la
|
||||
// conversion YUV→RGB + l'encodage JPEG à un Isolate → l'aperçu et le
|
||||
// déclencheur restent fluides (plus de gel du thread UI).
|
||||
final params = _FrameConvertParams(
|
||||
width: cameraImage.width,
|
||||
height: cameraImage.height,
|
||||
yBytes: Uint8List.fromList(cameraImage.planes[0].bytes),
|
||||
uBytes: Uint8List.fromList(cameraImage.planes[1].bytes),
|
||||
vBytes: Uint8List.fromList(cameraImage.planes[2].bytes),
|
||||
yRowStride: cameraImage.planes[0].bytesPerRow,
|
||||
uvRowStride: cameraImage.planes[1].bytesPerRow,
|
||||
uvPixelStride: cameraImage.planes[1].bytesPerPixel ?? 1,
|
||||
outputPath: tempPath,
|
||||
);
|
||||
|
||||
final bool ok =
|
||||
await Isolate.run(() => _convertAndEncodeFrameIsolate(params));
|
||||
if (!ok) {
|
||||
_isAnalyzingFrame = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60));
|
||||
|
||||
final result = await _opencvService.detectTarget(tempPath);
|
||||
|
||||
try {
|
||||
@@ -256,16 +351,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
setState(() {
|
||||
// On garde le résultat complet pour dessiner le cercle.
|
||||
_targetResult = result.success ? result : null;
|
||||
|
||||
if (!result.success) {
|
||||
_alignmentStatus = null;
|
||||
} else {
|
||||
final bool isCentered =
|
||||
result.centerX > 0.25 && result.centerX < 0.75 &&
|
||||
result.centerY > 0.25 && result.centerY < 0.75;
|
||||
final bool isSizedCorrectly = result.radius > 0.15;
|
||||
_alignmentStatus = isCentered && isSizedCorrectly;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -285,53 +370,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
_cameraController!.stopImageStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
_alignmentStatus = null;
|
||||
_targetResult = null;
|
||||
}
|
||||
|
||||
img.Image? _convertCameraImage(CameraImage cameraImage) {
|
||||
try {
|
||||
final int width = cameraImage.width;
|
||||
final int height = cameraImage.height;
|
||||
final int targetW = width ~/ 2;
|
||||
final int targetH = height ~/ 2;
|
||||
|
||||
final yPlane = cameraImage.planes[0];
|
||||
final uPlane = cameraImage.planes[1];
|
||||
final vPlane = cameraImage.planes[2];
|
||||
|
||||
final image = img.Image(width: targetW, height: targetH);
|
||||
|
||||
for (int y = 0; y < targetH; y++) {
|
||||
for (int x = 0; x < targetW; x++) {
|
||||
final int srcX = x * 2;
|
||||
final int srcY = y * 2;
|
||||
|
||||
final int yIndex = srcY * yPlane.bytesPerRow + srcX;
|
||||
final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow +
|
||||
(srcX ~/ 2) * uPlane.bytesPerPixel!;
|
||||
|
||||
final int yVal = yPlane.bytes[yIndex];
|
||||
final int uVal = uPlane.bytes[uvIndex] - 128;
|
||||
final int vVal = vPlane.bytes[uvIndex] - 128;
|
||||
|
||||
final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt();
|
||||
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal)
|
||||
.clamp(0, 255)
|
||||
.toInt();
|
||||
final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
|
||||
|
||||
image.setPixelRgb(x, y, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur conversion frame: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Galerie (inchangée)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -461,37 +502,23 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 1. Flux vidéo caméra
|
||||
// On utilise le ratio RÉEL du capteur (et non un 3/4 codé en dur) :
|
||||
// sinon l'aperçu est étiré dès que la résolution choisie n'est pas
|
||||
// en 4:3 (ex. veryHigh en 16:9 écrasait la cible en rectangle).
|
||||
// `value.aspectRatio` est le ratio paysage du flux → on l'inverse
|
||||
// pour l'affichage portrait.
|
||||
Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
aspectRatio: 1 / _cameraController!.value.aspectRatio,
|
||||
child: CameraPreview(_cameraController!),
|
||||
),
|
||||
),
|
||||
|
||||
// 1.bis Cercle dessiné autour de la cible circulaire détectée
|
||||
if (_targetResult != null && _targetResult!.success)
|
||||
Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: CustomPaint(
|
||||
painter: _TargetCirclePainter(
|
||||
target: _targetResult!,
|
||||
color: _targetReady ? frameColor : Colors.white,
|
||||
highlighted: _targetReady,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Cadre de visée avec coins et mire centrale
|
||||
// 2. Coins du cadre et mire centrale (sans le cadre rectangulaire)
|
||||
Center(
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
height: MediaQuery.of(context).size.width * 0.85,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: frameColor, width: 2.0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildCameraCorner(TopLeft: true, color: frameColor),
|
||||
@@ -606,7 +633,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: _takePictureManually,
|
||||
onTap: _isCapturing ? null : _takePictureManually,
|
||||
child: Container(
|
||||
height: 80,
|
||||
width: 80,
|
||||
@@ -628,11 +655,115 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 6. Overlay bloquant pendant la capture/traitement : empêche de
|
||||
// bouger l'UI ou de relancer une capture tant que la photo n'est pas
|
||||
// chargée (absorbe tous les gestes). Deux phases distinctes pour
|
||||
// lever la confusion « puis-je bouger ? » :
|
||||
// • _capturedPreviewPath == null → capture physique en cours :
|
||||
// voile sur l'aperçu vivant + « Ne bougez pas ».
|
||||
// • _capturedPreviewPath != null → photo prise, traitement en
|
||||
// cours : on affiche l'image FIGÉE (le flux vivant disparaît)
|
||||
// + « Photo prise ✓ — vous pouvez bouger ».
|
||||
if (_isCapturing)
|
||||
Positioned.fill(
|
||||
child: AbsorbPointer(
|
||||
child: _capturedPreviewPath == null
|
||||
? _buildCapturingOverlay()
|
||||
: _buildProcessingOverlay(_capturedPreviewPath!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Overlay PHASE 1 : capture physique en cours → l'utilisateur doit rester
|
||||
// immobile. Voile semi-transparent par-dessus l'aperçu vivant.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Widget _buildCapturingOverlay() {
|
||||
return Container(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'NE BOUGEZ PAS — CAPTURE…',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Overlay PHASE 2 : photo capturée, traitement en cours. On affiche l'image
|
||||
// FIGÉE (l'aperçu vivant disparaît, plus d'ambiguïté) et on indique
|
||||
// clairement que l'utilisateur peut maintenant bouger.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Widget _buildProcessingOverlay(String imagePath) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Image figée de la photo prise → recouvre totalement le flux caméra
|
||||
Image.file(File(imagePath), fit: BoxFit.cover),
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF00FF00),
|
||||
size: 52,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'PHOTO PRISE',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00FF00),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Traitement… vous pouvez bouger',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// NOUVEAU : Widget affichant les angles pitch/roll en temps réel
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -735,55 +866,56 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Capture manuelle (inchangée, sauf ajout stop IMU)
|
||||
// Capture manuelle (MODIFIÉE : ajout dégradation post-capture pour perf)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Future<void> _takePictureManually() async {
|
||||
if (_cameraController == null || !_cameraController!.value.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
try {
|
||||
await _cameraController!.setZoomLevel(1.0);
|
||||
} catch (_) {}
|
||||
// Garde anti-double-capture : tout appui supplémentaire pendant le
|
||||
// traitement est ignoré (évite les photos multiples / états incohérents).
|
||||
if (_isCapturing) return;
|
||||
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
_isLoading = true;
|
||||
_capturedPreviewPath = null; // phase « capture en cours »
|
||||
});
|
||||
try {
|
||||
// On stoppe le flux d'analyse (obligatoire avant takePicture) puis on
|
||||
// capture IMMÉDIATEMENT, sans étape intermédiaire. Le reset de zoom
|
||||
// inutile (aucun zoom possible sur cet écran) a été retiré pour que la
|
||||
// photo parte sans délai perceptible.
|
||||
_stopAlignmentDetection();
|
||||
_stopParallelismDetection(); // NOUVEAU
|
||||
_stopParallelismDetection();
|
||||
|
||||
final XFile photo = await _cameraController!.takePicture();
|
||||
|
||||
// NOUVEAU : redressement automatique de la cible (warp perspective).
|
||||
// On écrit le résultat dans un fichier dédié, à côté de l'original.
|
||||
String finalPath = photo.path;
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final rectifiedPath =
|
||||
'${tempDir.path}/rectified_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
final result = await _rectifyService.rectify(
|
||||
inputPath: photo.path,
|
||||
outputPath: rectifiedPath,
|
||||
);
|
||||
|
||||
finalPath = result.outputPath;
|
||||
|
||||
if (mounted && result.rectified) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
'Cible redressée automatiquement '
|
||||
'(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Redressement ignoré: $e');
|
||||
finalPath = photo.path; // on garde la photo originale en secours
|
||||
// La photo est PHYSIQUEMENT capturée : on gèle l'aperçu en affichant
|
||||
// l'image prise (le flux caméra « vivant » disparaît) et on bascule le
|
||||
// message sur « vous pouvez bouger ». Tout ce qui suit n'est que du
|
||||
// traitement et n'exige plus l'immobilité de l'utilisateur.
|
||||
if (mounted) {
|
||||
setState(() => _capturedPreviewPath = photo.path);
|
||||
}
|
||||
|
||||
// Pas de redressement perspective automatique ici : il déformait l'image
|
||||
// (écrasement en carré + rotation de l'axe de l'ellipse) et gelait le
|
||||
// thread UI. Le recentrage ET la rotation se font manuellement à l'écran
|
||||
// de centrage suivant, qui est précis et non destructeur.
|
||||
String finalPath = photo.path;
|
||||
|
||||
// PERF : on dégrade fortement la photo juste après la capture.
|
||||
// Une résolution réduite suffit pour la calibration et le plotting,
|
||||
// et accélère nettement le décodage à chaque écran (chargement plotting).
|
||||
try {
|
||||
finalPath = await _downscaleForPipeline(finalPath);
|
||||
} catch (e) {
|
||||
debugPrint('Downscale ignoré: $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedImagePath = finalPath;
|
||||
_showLiveCamera = false;
|
||||
@@ -793,10 +925,74 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du clic photo: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isCapturing = false;
|
||||
_capturedPreviewPath = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recadre la photo sur la zone du viseur PUIS réduit sa résolution, en un
|
||||
/// seul décodage dans un Isolate (thread UI fluide).
|
||||
///
|
||||
/// 1) Recadrage viseur : carré centré à 85 % du petit côté de la photo. C'est
|
||||
/// EXACTEMENT le cadre des 4 coins verts/orange affiché à la capture
|
||||
/// (`SizedBox` de `0.85 * largeur écran`, centré, l'aperçu caméra remplissant
|
||||
/// la largeur). → l'écran de centrage ne reçoit QUE ce qui était visé entre
|
||||
/// les coins, sans le fond capturé autour : plus d'impression de « dézoom ».
|
||||
/// 2) Dégradation résolution : 1080 px de côté max + JPEG qualité 70, largement
|
||||
/// suffisant pour la détection visuelle et bien plus rapide à recharger.
|
||||
///
|
||||
/// Baisser `maxSide` (ex. 900) ou `quality` (ex. 60) accélère encore au prix
|
||||
/// d'un peu de finesse à l'écran.
|
||||
Future<String> _downscaleForPipeline(String sourcePath) async {
|
||||
// On résout le chemin de sortie sur le thread UI (path_provider a besoin
|
||||
// du canal de plateforme), puis tout le travail lourd (décodage, crop,
|
||||
// resize, réencodage JPEG) part dans un Isolate → aucun gel du thread UI.
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final outPath =
|
||||
'${tempDir.path}/downscaled_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
return Isolate.run(() {
|
||||
final bytes = File(sourcePath).readAsBytesSync();
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded == null) return sourcePath;
|
||||
|
||||
// 1) Recadrage sur la zone du viseur (carré centré à 85 % du petit côté).
|
||||
final int side = (math.min(decoded.width, decoded.height) * 0.85).round();
|
||||
final int cropX =
|
||||
((decoded.width - side) / 2).round().clamp(0, decoded.width - 1);
|
||||
final int cropY =
|
||||
((decoded.height - side) / 2).round().clamp(0, decoded.height - 1);
|
||||
img.Image result = img.copyCrop(
|
||||
decoded,
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
width: math.min(side, decoded.width - cropX),
|
||||
height: math.min(side, decoded.height - cropY),
|
||||
);
|
||||
|
||||
// 2) Dégradation résolution si nécessaire.
|
||||
const int maxSide = 1080;
|
||||
final int longest = math.max(result.width, result.height);
|
||||
if (longest > maxSide) {
|
||||
final double ratio = maxSide / longest;
|
||||
result = img.copyResize(
|
||||
result,
|
||||
width: (result.width * ratio).round(),
|
||||
height: (result.height * ratio).round(),
|
||||
interpolation: img.Interpolation.linear, // rapide
|
||||
);
|
||||
}
|
||||
|
||||
File(outPath).writeAsBytesSync(img.encodeJpg(result, quality: 70));
|
||||
return outPath;
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers UI (inchangés)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -861,9 +1057,10 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 90,
|
||||
// PERF : limite d'import plus agressive pour accélérer le pipeline
|
||||
maxWidth: 1280,
|
||||
maxHeight: 1280,
|
||||
imageQuality: 75,
|
||||
);
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
@@ -892,68 +1089,3 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Painter qui dessine un cercle autour de la cible circulaire détectée.
|
||||
// Le centre et le rayon viennent d'OpenCV (coordonnées normalisées 0..1).
|
||||
// Blanc tant que la cible n'est pas bien cadrée, couleur d'état sinon.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
class _TargetCirclePainter extends CustomPainter {
|
||||
final TargetDetectionResult target;
|
||||
final Color color;
|
||||
final bool highlighted;
|
||||
|
||||
_TargetCirclePainter({
|
||||
required this.target,
|
||||
required this.color,
|
||||
required this.highlighted,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Centre en pixels dans la zone de dessin
|
||||
final double cx = target.centerX * size.width;
|
||||
final double cy = target.centerY * size.height;
|
||||
// radius est normalisé par min(largeur, hauteur) côté OpenCV
|
||||
final double r = target.radius * math.min(size.width, size.height);
|
||||
|
||||
final Paint stroke = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = highlighted ? 3.5 : 2.0
|
||||
..color = color;
|
||||
|
||||
// Cercle principal autour de la cible
|
||||
canvas.drawCircle(Offset(cx, cy), r, stroke);
|
||||
|
||||
// Petite croix au centre détecté
|
||||
final Paint cross = Paint()
|
||||
..strokeWidth = 2.0
|
||||
..color = color;
|
||||
const double k = 10;
|
||||
canvas.drawLine(Offset(cx - k, cy), Offset(cx + k, cy), cross);
|
||||
canvas.drawLine(Offset(cx, cy - k), Offset(cx, cy + k), cross);
|
||||
|
||||
// Étiquette "CIBLE" quand elle est bien cadrée
|
||||
if (highlighted) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: ' CIBLE ',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
backgroundColor: color,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(canvas, Offset(cx - tp.width / 2, (cy - r - 20).clamp(0.0, size.height)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TargetCirclePainter old) =>
|
||||
old.target != target ||
|
||||
old.color != color ||
|
||||
old.highlighted != highlighted;
|
||||
}
|
||||
@@ -101,24 +101,9 @@ class _CropScreenState extends State<CropScreen> {
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||
: Column(
|
||||
children: [
|
||||
// Zone interactive de crop
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(20),
|
||||
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()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// TEXTE D'AIDE AJUSTÉ
|
||||
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -126,9 +111,11 @@ class _CropScreenState extends State<CropScreen> {
|
||||
children: [
|
||||
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Alignez et pivotez la cible sur la croix',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Alignez et pivotez la cible sur la croix',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -141,6 +128,36 @@ class _CropScreenState extends State<CropScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 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°
|
||||
@@ -238,21 +255,9 @@ class _CropScreenState extends State<CropScreen> {
|
||||
builder: (context, constraints) {
|
||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
|
||||
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
|
||||
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
// On passe de 0.95 à 0.85 pour matcher parfaitement avec l'appareil photo !
|
||||
_cropSize = math.min(displayWidth, displayHeight)* 0.85;
|
||||
// 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();
|
||||
@@ -274,7 +279,7 @@ class _CropScreenState extends State<CropScreen> {
|
||||
alignment: Alignment.center,
|
||||
child: Image.file(
|
||||
File(widget.imagePath),
|
||||
fit: BoxFit.contain,
|
||||
fit: BoxFit.cover,
|
||||
width: _viewportSize.width,
|
||||
height: _viewportSize.height,
|
||||
),
|
||||
@@ -345,21 +350,6 @@ class _CropScreenState extends State<CropScreen> {
|
||||
void _initializeImagePosition() {
|
||||
if (_imageSize == null) return;
|
||||
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
||||
|
||||
// 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;
|
||||
@@ -372,6 +362,61 @@ class _CropScreenState extends State<CropScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -389,23 +434,26 @@ class _CropScreenState extends State<CropScreen> {
|
||||
Future<void> _onCropConfirm() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
|
||||
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
|
||||
final savedScale = _scale;
|
||||
final savedOffset = _offset;
|
||||
_scale = 1.0;
|
||||
_offset = Offset.zero;
|
||||
// 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,
|
||||
);
|
||||
|
||||
final cropRect = _calculateCropRect();
|
||||
|
||||
// On restaure pour l'affichage (au cas où on revient en arrière)
|
||||
_scale = savedScale;
|
||||
_offset = savedOffset;
|
||||
|
||||
// AJOUT DE LA DECOUPE ET DU REDRESSEMENT GÉOMÉTRIQUE PHYSIQUE
|
||||
final croppedImagePath = await _cropService.cropToSquare(
|
||||
widget.imagePath,
|
||||
cropRect,
|
||||
// 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,
|
||||
);
|
||||
|
||||
@@ -419,6 +467,10 @@ class _CropScreenState extends State<CropScreen> {
|
||||
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,
|
||||
@@ -438,43 +490,4 @@ class _CropScreenState extends State<CropScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
CropRect _calculateCropRect() {
|
||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final scaledWidth = displayWidth * _scale;
|
||||
final scaledHeight = displayHeight * _scale;
|
||||
|
||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
||||
|
||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
||||
final imageTop = imageCenterY - scaledHeight / 2;
|
||||
|
||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
||||
|
||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
||||
final relCropWidth = _cropSize / scaledWidth;
|
||||
final relCropHeight = _cropSize / scaledHeight;
|
||||
|
||||
return CropRect(
|
||||
x: relCropLeft.clamp(0.0, 1.0),
|
||||
y: relCropTop.clamp(0.0, 1.0),
|
||||
width: relCropWidth.clamp(0.0, 1.0),
|
||||
height: relCropHeight.clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
late Weapon _weapon;
|
||||
List<MaintenanceEntry> _maintenance = [];
|
||||
int _totalRounds = 0;
|
||||
int _sessionCount = 0;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
final repository = context.read<SessionRepository>();
|
||||
final history = await repository.getMaintenanceHistory(_weapon.id);
|
||||
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
||||
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_maintenance = history;
|
||||
_totalRounds = rounds;
|
||||
_sessionCount = sessions;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Sessions',
|
||||
'N/A',
|
||||
_sessionCount.toString(),
|
||||
Icons.history,
|
||||
Colors.orange,
|
||||
),
|
||||
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
|
||||
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.all(8),
|
||||
minimumSize: const Size(36, 36),
|
||||
),
|
||||
onPressed: () => _showEditAccessoriesDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
||||
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
||||
@@ -232,6 +252,33 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
|
||||
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
|
||||
// (ex: Modérateur) se retrouve caché sous le clavier.
|
||||
Widget _autoScrollOnFocus(Widget child) {
|
||||
return Builder(
|
||||
builder: (context) => Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onFocusChange: (hasFocus) {
|
||||
if (!hasFocus) return;
|
||||
// On attend que le clavier ait fini de redimensionner la vue.
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (context.mounted) {
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditWeaponDialog(BuildContext context) async {
|
||||
final nameController = TextEditingController(text: _weapon.name);
|
||||
final caliberController = TextEditingController(text: _weapon.caliber);
|
||||
@@ -253,41 +300,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
||||
),
|
||||
TextField(
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: customNameController,
|
||||
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||
),
|
||||
)),
|
||||
DropdownButtonFormField<WeaponType>(
|
||||
value: selectedType,
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||
onChanged: (v) => setState(() => selectedType = v!),
|
||||
),
|
||||
TextField(
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: caliberController,
|
||||
decoration: const InputDecoration(labelText: 'Calibre'),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextField(
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: opticController,
|
||||
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||
),
|
||||
TextField(
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: silencerController,
|
||||
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||
),
|
||||
TextField(
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: triggerController,
|
||||
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Row(
|
||||
_autoScrollOnFocus(Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
@@ -305,12 +352,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: notesController,
|
||||
decoration: const InputDecoration(labelText: 'Notes'),
|
||||
maxLines: 2,
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -344,9 +391,116 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
|
||||
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
|
||||
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
|
||||
// pouvoir comparer les scores selon la configuration utilisée.
|
||||
void _showEditAccessoriesDialog(BuildContext context) async {
|
||||
final opticController = TextEditingController(text: _weapon.optic);
|
||||
final silencerController = TextEditingController(text: _weapon.silencer);
|
||||
final triggerController = TextEditingController(text: _weapon.trigger);
|
||||
final customNameController = TextEditingController(text: _weapon.customName);
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Modifier les accessoires'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
|
||||
'pour pouvoir comparer les scores selon la configuration.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: opticController,
|
||||
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: silencerController,
|
||||
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: triggerController,
|
||||
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||
)),
|
||||
_autoScrollOnFocus(TextField(
|
||||
controller: customNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Surnom de la configuration',
|
||||
hintText: 'ex: Echelon + Holosun',
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != true) return;
|
||||
|
||||
final newOptic = opticController.text.isEmpty ? null : opticController.text;
|
||||
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
|
||||
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
|
||||
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
|
||||
|
||||
// Rien à faire si aucun accessoire n'a changé.
|
||||
final unchanged = newOptic == _weapon.optic &&
|
||||
newSilencer == _weapon.silencer &&
|
||||
newTrigger == _weapon.trigger;
|
||||
if (unchanged) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
|
||||
if (newCustomName == null) {
|
||||
final accessories = [newOptic, newSilencer, newTrigger]
|
||||
.where((a) => a != null && a.isNotEmpty)
|
||||
.join(' / ');
|
||||
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
|
||||
}
|
||||
|
||||
final repository = context.read<SessionRepository>();
|
||||
final newWeapon = await repository.addWeapon(
|
||||
name: _weapon.name,
|
||||
type: _weapon.type,
|
||||
caliber: _weapon.caliber,
|
||||
magazineCount: _weapon.magazineCount,
|
||||
magazineCapacity: _weapon.magazineCapacity,
|
||||
notes: _weapon.notes,
|
||||
optic: newOptic,
|
||||
silencer: newSilencer,
|
||||
trigger: newTrigger,
|
||||
customName: newCustomName,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
|
||||
);
|
||||
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddMaintenanceDialog(BuildContext context) async {
|
||||
final descController = TextEditingController();
|
||||
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||
DateTime selectedDate = DateTime.now();
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -367,6 +521,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => selectedDate = picked);
|
||||
}
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date',
|
||||
suffixIcon: Icon(Icons.calendar_today, size: 18),
|
||||
),
|
||||
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -384,6 +559,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
type: selectedType,
|
||||
description: descController.text,
|
||||
roundsSinceLast: _totalRounds,
|
||||
date: selectedDate,
|
||||
);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@@ -76,26 +76,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
itemCount: _weapons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final weapon = _weapons[index];
|
||||
final accessories = _accessoryChips(weapon);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
child: Icon(
|
||||
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text('${weapon.type.displayName} • ${weapon.caliber}'),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
|
||||
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
@@ -104,12 +89,96 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||
},
|
||||
onLongPress: () => _confirmDelete(context, weapon),
|
||||
child: Padding(
|
||||
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
child: Icon(
|
||||
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (accessories.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: accessories,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
] else
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${weapon.type.displayName} • ${weapon.caliber}',
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
|
||||
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
|
||||
// Seuls les accessoires effectivement définis sont affichés ; la card
|
||||
// s'agrandit donc en fonction du nombre d'accessoires.
|
||||
List<Widget> _accessoryChips(Weapon weapon) {
|
||||
final items = <(IconData, String)>[];
|
||||
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
|
||||
items.add((Icons.center_focus_strong, weapon.optic!));
|
||||
}
|
||||
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
|
||||
items.add((Icons.volume_off, weapon.silencer!));
|
||||
}
|
||||
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
|
||||
items.add((Icons.touch_app, weapon.trigger!));
|
||||
}
|
||||
|
||||
return items.map((item) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(item.$2, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _showAddWeaponDialog(BuildContext context) async {
|
||||
final nameController = TextEditingController();
|
||||
final caliberController = TextEditingController();
|
||||
|
||||
@@ -25,12 +25,43 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
List<Session> _recentSessions = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
SessionProvider? _sessionProvider;
|
||||
bool _wasSessionActive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final provider = context.read<SessionProvider>();
|
||||
if (provider != _sessionProvider) {
|
||||
_sessionProvider?.removeListener(_onSessionChanged);
|
||||
_sessionProvider = provider;
|
||||
_sessionProvider!.addListener(_onSessionChanged);
|
||||
_wasSessionActive = provider.isSessionActive;
|
||||
}
|
||||
}
|
||||
|
||||
void _onSessionChanged() {
|
||||
final isActive = _sessionProvider?.isSessionActive ?? false;
|
||||
// Quand une session vient de se terminer, on rafraîchit les stats
|
||||
// automatiquement pour prendre en compte la dernière session.
|
||||
if (_wasSessionActive && !isActive) {
|
||||
_loadStats();
|
||||
}
|
||||
_wasSessionActive = isActive;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sessionProvider?.removeListener(_onSessionChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadStats() async {
|
||||
final repository = context.read<SessionRepository>();
|
||||
final stats = await repository.getStatistics();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../data/models/session.dart';
|
||||
import '../../data/models/target_analysis.dart';
|
||||
import '../../data/models/weapon.dart';
|
||||
|
||||
class SessionProvider extends ChangeNotifier {
|
||||
DateTime? _sessionDate;
|
||||
|
||||
@@ -85,8 +85,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
void _showThemeDialog() {
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -247,6 +245,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Rapport de bug autonome : on collecte une description + les infos
|
||||
// techniques, et on copie un rapport prêt à coller dans un email de support.
|
||||
void _showReportBugDialog() {
|
||||
final descController = TextEditingController();
|
||||
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
|
||||
const appVersion = '1.0.0';
|
||||
final platform = Theme.of(context).platform.name;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Signaler un bug'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descController,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Infos techniques jointes : version $appVersion • $platform',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copier le rapport'),
|
||||
onPressed: () {
|
||||
final desc = descController.text.trim();
|
||||
final report = StringBuffer()
|
||||
..writeln('--- Rapport de bug ---')
|
||||
..writeln('Version : $appVersion')
|
||||
..writeln('Plateforme : $platform')
|
||||
..writeln('Date : ${DateTime.now().toIso8601String()}')
|
||||
..writeln('')
|
||||
..writeln('Description :')
|
||||
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
|
||||
|
||||
Clipboard.setData(ClipboardData(text: report.toString()));
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -347,6 +418,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: '1.0.0',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.bug_report_outlined,
|
||||
title: 'Signaler un bug',
|
||||
subtitle: 'Aidez-nous à corriger les problèmes',
|
||||
onTap: _showReportBugDialog,
|
||||
),
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
||||
import '../../core/widgets/metric_info_button.dart';
|
||||
import '../../data/models/session.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
import '../../services/statistics_service.dart';
|
||||
@@ -29,6 +30,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
List<String> _availableWeapons = ['Toutes'];
|
||||
List<String> _availableDistances = ['Toutes'];
|
||||
|
||||
// --- Comparateur de sessions ---
|
||||
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
|
||||
// un switch permet d'alterner l'affichage des stats entre la session A et B.
|
||||
Session? _compareA;
|
||||
Session? _compareB;
|
||||
bool _showingB = false; // false = on affiche A, true = on affiche B
|
||||
bool get _compareMode => _compareA != null && _compareB != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -79,6 +88,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
}
|
||||
|
||||
void _calculateStats() {
|
||||
// Mode comparaison : on calcule les stats sur la seule session active
|
||||
// (A ou B selon le switch), sans filtre de période.
|
||||
if (_compareMode) {
|
||||
final active = _showingB ? _compareB! : _compareA!;
|
||||
_statistics = _statisticsService.calculateStatistics(
|
||||
[active],
|
||||
period: StatsPeriod.all,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filtrer les sessions avant calcul
|
||||
final filteredSessions = _allSessions.where((s) {
|
||||
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
|
||||
@@ -92,6 +112,80 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _sessionLabel(Session s) {
|
||||
final d = s.createdAt;
|
||||
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
||||
return '${s.weapon} • $date • ${s.totalScore} pts';
|
||||
}
|
||||
|
||||
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
|
||||
Future<void> _openCompareDialog() async {
|
||||
if (_allSessions.length < 2) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Session? a = _compareA ?? _allSessions[0];
|
||||
Session? b = _compareB ?? _allSessions[1];
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: const Text('Comparer 2 sessions'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<Session>(
|
||||
initialValue: a,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Session A'),
|
||||
items: _allSessions
|
||||
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => a = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<Session>(
|
||||
initialValue: b,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Session B'),
|
||||
items: _allSessions
|
||||
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => b = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && a != null && b != null) {
|
||||
setState(() {
|
||||
_compareA = a;
|
||||
_compareB = b;
|
||||
_showingB = false;
|
||||
_calculateStats();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _exitCompareMode() {
|
||||
setState(() {
|
||||
_compareA = null;
|
||||
_compareB = null;
|
||||
_showingB = false;
|
||||
_calculateStats();
|
||||
});
|
||||
}
|
||||
|
||||
List<double> _getScoreHistory() {
|
||||
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
||||
// Sort sessions by date and take last 10
|
||||
@@ -162,6 +256,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 1bis. COMPARATEUR DE SESSIONS
|
||||
_buildComparator(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||
@@ -191,18 +289,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
'Score',
|
||||
'${_statistics?.totalScore ?? 0}',
|
||||
_getScoreHistory(),
|
||||
explanations: const [
|
||||
MetricExplanation(
|
||||
'Score',
|
||||
'Total des points marqués sur la période sélectionnée.',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildChartSection(
|
||||
'Précision',
|
||||
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
||||
_getPrecisionHistory(),
|
||||
explanations: const [
|
||||
MetricExplanation(
|
||||
'Précision',
|
||||
'Proximité moyenne de vos impacts par rapport au centre '
|
||||
'de la cible (calculée sur les distances).',
|
||||
),
|
||||
MetricExplanation(
|
||||
'À ne pas confondre',
|
||||
'C\'est différent de la « Réussite » affichée dans une '
|
||||
'session, qui se base sur les points marqués. Les deux '
|
||||
'mesurent des choses distinctes, leurs pourcentages '
|
||||
'ne sont donc pas identiques.',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildChartSection(
|
||||
'Groupement moyen',
|
||||
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
|
||||
'Étalement moyen',
|
||||
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
|
||||
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
|
||||
explanations: const [
|
||||
MetricExplanation(
|
||||
'Étalement moyen',
|
||||
'Étalement moyen de vos groupements : distance entre les '
|
||||
'impacts les plus éloignés, en % de la largeur de '
|
||||
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 25),
|
||||
@@ -228,6 +354,94 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
|
||||
Widget _buildComparator() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (!_compareMode) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openCompareDialog,
|
||||
icon: const Icon(Icons.compare_arrows),
|
||||
label: const Text('Comparer 2 sessions'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final activeColor = const Color(0xFF1A73E8);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: activeColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.compare_arrows, color: activeColor, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'Quitter la comparaison',
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: _exitCompareMode,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Switch pour alterner entre les 2 sessions.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'A · ${_sessionLabel(_compareA!)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
|
||||
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _showingB,
|
||||
activeThumbColor: activeColor,
|
||||
onChanged: (v) => setState(() {
|
||||
_showingB = v;
|
||||
_calculateStats();
|
||||
}),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'B · ${_sessionLabel(_compareB!)}',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
|
||||
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Affichage : session ${_showingB ? 'B' : 'A'}',
|
||||
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdown(
|
||||
String label,
|
||||
String value,
|
||||
@@ -302,8 +516,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
Widget _buildChartSection(
|
||||
String title,
|
||||
String value,
|
||||
List<double> dataPoints,
|
||||
) {
|
||||
List<double> dataPoints, {
|
||||
List<MetricExplanation>? explanations,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -314,9 +529,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
|
||||
),
|
||||
if (explanations != null) ...[
|
||||
const Spacer(),
|
||||
MetricInfoButton(title: title, explanations: explanations),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
|
||||
@@ -98,11 +98,152 @@ void _cropIsolateEntry(_CropParams params) {
|
||||
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
||||
}
|
||||
|
||||
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
|
||||
class _ViewportCropParams {
|
||||
final String sourcePath;
|
||||
final double offsetDx;
|
||||
final double offsetDy;
|
||||
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
|
||||
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
|
||||
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
|
||||
final double rotationDegrees;
|
||||
final int outputSize;
|
||||
final String outputPath;
|
||||
|
||||
_ViewportCropParams({
|
||||
required this.sourcePath,
|
||||
required this.offsetDx,
|
||||
required this.offsetDy,
|
||||
required this.displayPerSourcePx,
|
||||
required this.cropSizeDisplay,
|
||||
required this.zoomScale,
|
||||
required this.rotationDegrees,
|
||||
required this.outputSize,
|
||||
required this.outputPath,
|
||||
});
|
||||
}
|
||||
|
||||
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
|
||||
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
|
||||
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
|
||||
// l'a placée, même collée à un bord (aucun recentrage forcé).
|
||||
void _viewportCropIsolateEntry(_ViewportCropParams p) {
|
||||
final bytes = File(p.sourcePath).readAsBytesSync();
|
||||
final img.Image? src = img.decodeImage(bytes);
|
||||
if (src == null) {
|
||||
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
|
||||
}
|
||||
|
||||
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
|
||||
img.Image rotated = src;
|
||||
if (p.rotationDegrees != 0.0) {
|
||||
rotated = img.copyRotate(
|
||||
src,
|
||||
angle: p.rotationDegrees,
|
||||
interpolation: img.Interpolation.linear,
|
||||
);
|
||||
}
|
||||
|
||||
final double f = p.displayPerSourcePx;
|
||||
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
|
||||
// zoom) → le champ de vision capturé est toujours celui de l'image non
|
||||
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
|
||||
final int side = math.max(1, (p.cropSizeDisplay / f).round());
|
||||
|
||||
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
|
||||
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
|
||||
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
|
||||
// ICI (uniquement pour le pointage) afin que la croix vise le même point
|
||||
// quel que soit le niveau de zoom.
|
||||
final double fz = f * p.zoomScale;
|
||||
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
|
||||
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
|
||||
|
||||
final int srcX = (cropCenterX - side / 2).round();
|
||||
final int srcY = (cropCenterY - side / 2).round();
|
||||
|
||||
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
|
||||
final out = img.Image(width: side, height: side, numChannels: 3);
|
||||
|
||||
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
|
||||
// déborde reste noir (padding) → aucun recentrage forcé.
|
||||
final int vx0 = math.max(0, srcX);
|
||||
final int vy0 = math.max(0, srcY);
|
||||
final int vx1 = math.min(rotated.width, srcX + side);
|
||||
final int vy1 = math.min(rotated.height, srcY + side);
|
||||
final int vw = vx1 - vx0;
|
||||
final int vh = vy1 - vy0;
|
||||
|
||||
if (vw > 0 && vh > 0) {
|
||||
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
|
||||
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
|
||||
// (sinon compositeImage étire la source pour remplir la destination).
|
||||
img.compositeImage(
|
||||
out,
|
||||
region,
|
||||
dstX: vx0 - srcX,
|
||||
dstY: vy0 - srcY,
|
||||
dstW: vw,
|
||||
dstH: vh,
|
||||
);
|
||||
}
|
||||
|
||||
img.Image result = out;
|
||||
if (side != p.outputSize) {
|
||||
result = img.copyResize(
|
||||
out,
|
||||
width: p.outputSize,
|
||||
height: p.outputSize,
|
||||
interpolation: img.Interpolation.linear,
|
||||
);
|
||||
}
|
||||
|
||||
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
|
||||
}
|
||||
|
||||
class ImageCropService {
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
static const int maxOutputSize = 1024;
|
||||
|
||||
/// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage.
|
||||
/// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et
|
||||
/// tout débordement hors de l'image est rempli en noir (jamais de recentrage).
|
||||
///
|
||||
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
|
||||
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
|
||||
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
|
||||
Future<String> cropViewport({
|
||||
required String sourcePath,
|
||||
required double offsetDx,
|
||||
required double offsetDy,
|
||||
required double displayPerSourcePx,
|
||||
required double cropSizeDisplay,
|
||||
double zoomScale = 1.0,
|
||||
double rotationDegrees = 0.0,
|
||||
int outputSize = maxOutputSize,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
||||
|
||||
final params = _ViewportCropParams(
|
||||
sourcePath: sourcePath,
|
||||
offsetDx: offsetDx,
|
||||
offsetDy: offsetDy,
|
||||
displayPerSourcePx: displayPerSourcePx,
|
||||
cropSizeDisplay: cropSizeDisplay,
|
||||
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
|
||||
rotationDegrees: rotationDegrees,
|
||||
outputSize: outputSize,
|
||||
outputPath: outputPath,
|
||||
);
|
||||
|
||||
// Traitement lourd dans un Isolate → thread UI fluide.
|
||||
await Isolate.run(() => _viewportCropIsolateEntry(params));
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
Future<String> cropToSquare(
|
||||
String sourcePath,
|
||||
CropRect cropRect, {
|
||||
|
||||
@@ -119,7 +119,6 @@ class ParallelismService {
|
||||
|
||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||
final double nx = gx / magnitude;
|
||||
final double ny = gy / magnitude;
|
||||
final double nz = gz / magnitude;
|
||||
|
||||
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
||||
|
||||
Reference in New Issue
Block a user