Compare commits
11 Commits
avec-mlkit
...
V.0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a102bfd5ef | ||
|
|
89b4f433b6 | ||
|
|
9fa3a6f46d | ||
|
|
8165d3bab3 | ||
|
|
ba9975f047 | ||
|
|
f7fecf0ef2 | ||
|
|
0564bdbb48 | ||
|
|
05356aaaea | ||
|
|
c4880ccb68 | ||
|
|
00ae0117c5 | ||
|
|
1d8124c8d8 |
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
|||||||
import 'core/theme/theme_provider.dart';
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'main_navigation_holder.dart';
|
import 'main_navigation_holder.dart';
|
||||||
import 'features/home/home_screen.dart';
|
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
class BullyApp extends StatelessWidget {
|
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:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
@@ -11,6 +10,12 @@ import '../../core/constants/app_constants.dart';
|
|||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static DatabaseHelper? _instance;
|
static DatabaseHelper? _instance;
|
||||||
static Database? _database;
|
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();
|
DatabaseHelper._internal();
|
||||||
|
|
||||||
@@ -20,7 +25,9 @@ class DatabaseHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
_database ??= await _initDatabase();
|
if (_database != null) return _database!;
|
||||||
|
_initFuture ??= _initDatabase();
|
||||||
|
_database = await _initFuture!;
|
||||||
return _database!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +517,16 @@ class DatabaseHelper {
|
|||||||
return Sqflite.firstIntValue(result) ?? 0;
|
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 {
|
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return await db.insert(
|
return await db.insert(
|
||||||
@@ -543,5 +560,6 @@ class DatabaseHelper {
|
|||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
|
_initFuture = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,18 +182,23 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getSessionCountForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addMaintenanceEntry({
|
Future<void> addMaintenanceEntry({
|
||||||
required String weaponId,
|
required String weaponId,
|
||||||
required MaintenanceType type,
|
required MaintenanceType type,
|
||||||
required String description,
|
required String description,
|
||||||
int? roundsSinceLast,
|
int? roundsSinceLast,
|
||||||
|
DateTime? date,
|
||||||
}) async {
|
}) async {
|
||||||
final entry = MaintenanceEntry(
|
final entry = MaintenanceEntry(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
weaponId: weaponId,
|
weaponId: weaponId,
|
||||||
type: type,
|
type: type,
|
||||||
description: description,
|
description: description,
|
||||||
date: DateTime.now(),
|
date: date ?? DateTime.now(),
|
||||||
roundsSinceLastMaintenance: roundsSinceLast,
|
roundsSinceLastMaintenance: roundsSinceLast,
|
||||||
);
|
);
|
||||||
await _databaseHelper.insertMaintenance(entry);
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
TransformationController();
|
TransformationController();
|
||||||
final GlobalKey _imageKey = GlobalKey();
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
double _currentZoomScale = 1.0;
|
double _currentZoomScale = 1.0;
|
||||||
String? _movingShotId;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -269,7 +268,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
_openImpactEditor(context.read<AnalysisProvider>());
|
_openImpactEditor(context.read<AnalysisProvider>());
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'TERMINER',
|
'VALIDER',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/theme/app_theme.dart';
|
|
||||||
import '../../data/models/shot.dart';
|
import '../../data/models/shot.dart';
|
||||||
import 'analysis_provider.dart';
|
import 'analysis_provider.dart';
|
||||||
import 'widgets/target_overlay.dart';
|
import 'widgets/target_overlay.dart';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/metric_info_button.dart';
|
||||||
import '../../../services/grouping_analyzer_service.dart';
|
import '../../../services/grouping_analyzer_service.dart';
|
||||||
|
|
||||||
class GroupingStats extends StatelessWidget {
|
class GroupingStats extends StatelessWidget {
|
||||||
@@ -43,31 +44,64 @@ class GroupingStats extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
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(),
|
const Spacer(),
|
||||||
_buildQualityBadge(context),
|
_buildQualityBadge(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
children: [
|
||||||
_buildStat(
|
Expanded(
|
||||||
context,
|
child: _buildStat(
|
||||||
'Diametre',
|
context,
|
||||||
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
'Étalement',
|
||||||
icon: Icons.straighten,
|
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
||||||
|
icon: Icons.straighten,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildStat(
|
Expanded(
|
||||||
context,
|
child: _buildStat(
|
||||||
'Dispersion',
|
context,
|
||||||
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
'Dispersion',
|
||||||
icon: Icons.scatter_plot,
|
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
||||||
|
icon: Icons.scatter_plot,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildStat(
|
Expanded(
|
||||||
context,
|
child: _buildStat(
|
||||||
'Decalage',
|
context,
|
||||||
offsetDescription,
|
'Décalage',
|
||||||
icon: Icons.compare_arrows,
|
offsetDescription,
|
||||||
|
icon: Icons.compare_arrows,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
|
|||||||
|
|
||||||
String _getOffsetDescription(double offsetX, double offsetY) {
|
String _getOffsetDescription(double offsetX, double offsetY) {
|
||||||
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
||||||
return 'Centre';
|
return 'Centré';
|
||||||
}
|
}
|
||||||
|
|
||||||
String vertical = '';
|
String vertical = '';
|
||||||
String horizontal = '';
|
String horizontal = '';
|
||||||
|
|
||||||
if (offsetY < -0.02) {
|
if (offsetY < -0.02) {
|
||||||
vertical = 'H';
|
vertical = 'Haut';
|
||||||
} else if (offsetY > 0.02) {
|
} else if (offsetY > 0.02) {
|
||||||
vertical = 'B';
|
vertical = 'Bas';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offsetX < -0.02) {
|
if (offsetX < -0.02) {
|
||||||
horizontal = 'G';
|
horizontal = 'Gauche';
|
||||||
} else if (offsetX > 0.02) {
|
} else if (offsetX > 0.02) {
|
||||||
horizontal = 'D';
|
horizontal = 'Droite';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/metric_info_button.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
import '../../../services/score_calculator_service.dart';
|
import '../../../services/score_calculator_service.dart';
|
||||||
|
|
||||||
@@ -44,6 +45,31 @@ class ScoreCard extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
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(),
|
const Divider(),
|
||||||
@@ -67,11 +93,12 @@ class ScoreCard extends StatelessWidget {
|
|||||||
shotCount > 0
|
shotCount > 0
|
||||||
? (totalScore / shotCount).toStringAsFixed(1)
|
? (totalScore / shotCount).toStringAsFixed(1)
|
||||||
: '-',
|
: '-',
|
||||||
|
subtitle: '/ $maxScore',
|
||||||
),
|
),
|
||||||
if (scoreResult != null)
|
if (scoreResult != null)
|
||||||
_buildScoreStat(
|
_buildScoreStat(
|
||||||
context,
|
context,
|
||||||
'Pourcentage',
|
'Réussite',
|
||||||
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
/// Les anneaux sont répartis proportionnellement.
|
/// Les anneaux sont répartis proportionnellement.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
@@ -338,24 +337,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white10),
|
border: Border.all(color: Colors.white10),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
_buildSignLabel('−'),
|
||||||
Row(
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||||
const SizedBox(width: 40),
|
Row(
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
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) {
|
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -15,12 +16,77 @@ import '../../data/models/target_type.dart';
|
|||||||
import '../crop/crop_screen.dart';
|
import '../crop/crop_screen.dart';
|
||||||
import '../session/session_provider.dart';
|
import '../session/session_provider.dart';
|
||||||
import 'widgets/image_source_button.dart';
|
import 'widgets/image_source_button.dart';
|
||||||
import '../../services/image_crop_service.dart';
|
|
||||||
import '../../services/opencv_target_service.dart';
|
import '../../services/opencv_target_service.dart';
|
||||||
import '../../services/parallelism_service.dart'; // NOUVEAU
|
import '../../services/parallelism_service.dart'; // NOUVEAU
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path_provider/path_provider.dart';
|
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 {
|
class CaptureScreen extends StatefulWidget {
|
||||||
const CaptureScreen({super.key});
|
const CaptureScreen({super.key});
|
||||||
|
|
||||||
@@ -31,7 +97,6 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
class _CaptureScreenState extends State<CaptureScreen>
|
class _CaptureScreenState extends State<CaptureScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
final ImageCropService _cropService = ImageCropService();
|
|
||||||
final TargetType _selectedType = TargetType.concentric;
|
final TargetType _selectedType = TargetType.concentric;
|
||||||
final OpenCVTargetService _opencvService = OpenCVTargetService();
|
final OpenCVTargetService _opencvService = OpenCVTargetService();
|
||||||
|
|
||||||
@@ -56,15 +121,12 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
// Caméra live
|
// Caméra live
|
||||||
CameraController? _cameraController;
|
CameraController? _cameraController;
|
||||||
List<CameraDescription>? _cameras;
|
List<CameraDescription>? _cameras;
|
||||||
bool _isCameraInitialized = false;
|
|
||||||
bool _showLiveCamera = false;
|
bool _showLiveCamera = false;
|
||||||
|
|
||||||
// Animation pulsation du viseur
|
// Animation pulsation du viseur
|
||||||
late AnimationController _scanAnimationController;
|
late AnimationController _scanAnimationController;
|
||||||
late Animation<double> _scanAnimation;
|
|
||||||
|
|
||||||
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
|
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
|
||||||
bool? _alignmentStatus;
|
|
||||||
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
|
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
|
||||||
Timer? _detectionTimer;
|
Timer? _detectionTimer;
|
||||||
bool _isAnalyzingFrame = false;
|
bool _isAnalyzingFrame = false;
|
||||||
@@ -80,10 +142,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
)..repeat(reverse: true);
|
)..repeat(reverse: true);
|
||||||
|
|
||||||
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
|
||||||
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -104,10 +162,23 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
_parallelismService.start();
|
_parallelismService.start();
|
||||||
|
|
||||||
_parallelismSubscription = _parallelismService.stream.listen((data) {
|
_parallelismSubscription = _parallelismService.stream.listen((data) {
|
||||||
if (mounted && _showLiveCamera) {
|
if (!mounted || !_showLiveCamera) return;
|
||||||
setState(() {
|
|
||||||
_parallelismData = data;
|
// 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(() {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,7 +261,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
if (_cameraController != null) {
|
if (_cameraController != null) {
|
||||||
await _cameraController!.dispose();
|
await _cameraController!.dispose();
|
||||||
_cameraController = null;
|
_cameraController = null;
|
||||||
_isCameraInitialized = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_cameras = await availableCameras();
|
_cameras = await availableCameras();
|
||||||
@@ -211,9 +281,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
await _cameraController!.initialize();
|
await _cameraController!.initialize();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isCameraInitialized = true;
|
|
||||||
_showLiveCamera = true;
|
_showLiveCamera = true;
|
||||||
_alignmentStatus = null;
|
|
||||||
_targetResult = null; // reset détection cible
|
_targetResult = null; // reset détection cible
|
||||||
_parallelismData = null; // NOUVEAU : reset IMU
|
_parallelismData = null; // NOUVEAU : reset IMU
|
||||||
});
|
});
|
||||||
@@ -235,30 +303,44 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
_detectionTimer?.cancel();
|
_detectionTimer?.cancel();
|
||||||
_detectionTimer = null;
|
_detectionTimer = null;
|
||||||
|
|
||||||
DateTime? _lastAnalysis;
|
DateTime? lastAnalysis;
|
||||||
|
|
||||||
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
||||||
if (_isAnalyzingFrame) return;
|
if (_isAnalyzingFrame) return;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
|
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
|
||||||
if (_lastAnalysis != null &&
|
if (lastAnalysis != null &&
|
||||||
now.difference(_lastAnalysis!).inMilliseconds < 1000) return;
|
now.difference(lastAnalysis!).inMilliseconds < 1000) return;
|
||||||
_lastAnalysis = now;
|
lastAnalysis = now;
|
||||||
_isAnalyzingFrame = true;
|
_isAnalyzingFrame = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final tempPath = '${tempDir.path}/frame_analysis.jpg';
|
final tempPath = '${tempDir.path}/frame_analysis.jpg';
|
||||||
|
|
||||||
final img.Image? converted = _convertCameraImage(cameraImage);
|
// On copie les octets des plans MAINTENANT (le buffer caméra est
|
||||||
if (converted == null) {
|
// 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;
|
_isAnalyzingFrame = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PERF : qualité 50 pour la frame d'analyse temps réel (jetable)
|
|
||||||
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 50));
|
|
||||||
|
|
||||||
final result = await _opencvService.detectTarget(tempPath);
|
final result = await _opencvService.detectTarget(tempPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -269,16 +351,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
// On garde le résultat complet pour dessiner le cercle.
|
// On garde le résultat complet pour dessiner le cercle.
|
||||||
_targetResult = result.success ? result : null;
|
_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) {
|
} catch (e) {
|
||||||
@@ -298,53 +370,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
_cameraController!.stopImageStream();
|
_cameraController!.stopImageStream();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_alignmentStatus = null;
|
|
||||||
_targetResult = 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)
|
// Galerie (inchangée)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -907,16 +935,23 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Réduit fortement la résolution de la photo pour accélérer tous les
|
/// Recadre la photo sur la zone du viseur PUIS réduit sa résolution, en un
|
||||||
/// écrans suivants (crop, calibration, plotting). 1080 px de côté max
|
/// seul décodage dans un Isolate (thread UI fluide).
|
||||||
/// + JPEG qualité 70 : largement suffisant pour la détection visuelle.
|
|
||||||
///
|
///
|
||||||
/// Baisser `maxSide` (ex. 900) ou `quality` (ex. 60) rend le chargement
|
/// 1) Recadrage viseur : carré centré à 85 % du petit côté de la photo. C'est
|
||||||
/// encore plus rapide au prix d'un peu de finesse à l'écran.
|
/// 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 {
|
Future<String> _downscaleForPipeline(String sourcePath) async {
|
||||||
// On résout le chemin de sortie sur le thread UI (path_provider a besoin
|
// 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, resize,
|
// du canal de plateforme), puis tout le travail lourd (décodage, crop,
|
||||||
// réencodage JPEG) part dans un Isolate → aucun gel du thread UI.
|
// resize, réencodage JPEG) part dans un Isolate → aucun gel du thread UI.
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final outPath =
|
final outPath =
|
||||||
'${tempDir.path}/downscaled_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
'${tempDir.path}/downscaled_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
@@ -926,21 +961,34 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
final decoded = img.decodeImage(bytes);
|
final decoded = img.decodeImage(bytes);
|
||||||
if (decoded == null) return sourcePath;
|
if (decoded == null) return sourcePath;
|
||||||
|
|
||||||
const int maxSide = 1080;
|
// 1) Recadrage sur la zone du viseur (carré centré à 85 % du petit côté).
|
||||||
final int longest = math.max(decoded.width, decoded.height);
|
final int side = (math.min(decoded.width, decoded.height) * 0.85).round();
|
||||||
if (longest <= maxSide) {
|
final int cropX =
|
||||||
return sourcePath; // déjà assez petite, rien à faire
|
((decoded.width - side) / 2).round().clamp(0, decoded.width - 1);
|
||||||
}
|
final int cropY =
|
||||||
|
((decoded.height - side) / 2).round().clamp(0, decoded.height - 1);
|
||||||
final double ratio = maxSide / longest;
|
img.Image result = img.copyCrop(
|
||||||
final resized = img.copyResize(
|
|
||||||
decoded,
|
decoded,
|
||||||
width: (decoded.width * ratio).round(),
|
x: cropX,
|
||||||
height: (decoded.height * ratio).round(),
|
y: cropY,
|
||||||
interpolation: img.Interpolation.linear, // rapide
|
width: math.min(side, decoded.width - cropX),
|
||||||
|
height: math.min(side, decoded.height - cropY),
|
||||||
);
|
);
|
||||||
|
|
||||||
File(outPath).writeAsBytesSync(img.encodeJpg(resized, quality: 70));
|
// 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;
|
return outPath;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,24 +101,9 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
// Zone interactive de crop
|
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
||||||
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É
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -126,9 +111,11 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Flexible(
|
||||||
'Alignez et pivotez la cible sur la croix',
|
child: Text(
|
||||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
'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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||||||
@@ -238,21 +255,9 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
_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 !
|
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
|
||||||
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
|
// visée = toute la zone visible → aucun bord noir autour du cadre.
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
_cropSize = math.min(_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;
|
|
||||||
|
|
||||||
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
||||||
_initializeImagePosition();
|
_initializeImagePosition();
|
||||||
@@ -274,7 +279,7 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(widget.imagePath),
|
File(widget.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.cover,
|
||||||
width: _viewportSize.width,
|
width: _viewportSize.width,
|
||||||
height: _viewportSize.height,
|
height: _viewportSize.height,
|
||||||
),
|
),
|
||||||
@@ -345,21 +350,6 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
void _initializeImagePosition() {
|
void _initializeImagePosition() {
|
||||||
if (_imageSize == null) return;
|
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)
|
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
|
||||||
_scale = widget.initialScale ?? 1.0;
|
_scale = widget.initialScale ?? 1.0;
|
||||||
if (_scale < 1.0 && widget.initialScale == null) _scale = 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) {
|
void _onScaleStart(ScaleStartDetails details) {
|
||||||
_baseScale = _scale;
|
_baseScale = _scale;
|
||||||
_startFocalPoint = details.focalPoint;
|
_startFocalPoint = details.focalPoint;
|
||||||
@@ -389,14 +434,13 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
Future<void> _onCropConfirm() async {
|
Future<void> _onCropConfirm() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
// Facteur d'échelle affichage/source (BoxFit.contain) — identique à
|
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
|
||||||
// celui utilisé pour afficher l'image dans l'aperçu.
|
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
final double displayPerSourcePx = math.max(
|
||||||
final double displayWidth = imageAspect > viewportAspect
|
_viewportSize.width / _imageSize!.width,
|
||||||
? _viewportSize.width
|
_viewportSize.height / _imageSize!.height,
|
||||||
: _viewportSize.height * imageAspect;
|
);
|
||||||
final double displayPerSourcePx = displayWidth / _imageSize!.width;
|
|
||||||
|
|
||||||
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
|
// 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
|
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
late Weapon _weapon;
|
late Weapon _weapon;
|
||||||
List<MaintenanceEntry> _maintenance = [];
|
List<MaintenanceEntry> _maintenance = [];
|
||||||
int _totalRounds = 0;
|
int _totalRounds = 0;
|
||||||
|
int _sessionCount = 0;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final history = await repository.getMaintenanceHistory(_weapon.id);
|
final history = await repository.getMaintenanceHistory(_weapon.id);
|
||||||
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
||||||
|
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_maintenance = history;
|
_maintenance = history;
|
||||||
_totalRounds = rounds;
|
_totalRounds = rounds;
|
||||||
|
_sessionCount = sessions;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatCard(
|
child: _buildStatCard(
|
||||||
'Sessions',
|
'Sessions',
|
||||||
'N/A',
|
_sessionCount.toString(),
|
||||||
Icons.history,
|
Icons.history,
|
||||||
Colors.orange,
|
Colors.orange,
|
||||||
),
|
),
|
||||||
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(),
|
const Divider(),
|
||||||
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
||||||
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
_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 {
|
void _showEditWeaponDialog(BuildContext context) async {
|
||||||
final nameController = TextEditingController(text: _weapon.name);
|
final nameController = TextEditingController(text: _weapon.name);
|
||||||
final caliberController = TextEditingController(text: _weapon.caliber);
|
final caliberController = TextEditingController(text: _weapon.caliber);
|
||||||
@@ -253,41 +300,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: customNameController,
|
controller: customNameController,
|
||||||
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||||
),
|
)),
|
||||||
DropdownButtonFormField<WeaponType>(
|
DropdownButtonFormField<WeaponType>(
|
||||||
value: selectedType,
|
value: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type'),
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
onChanged: (v) => setState(() => selectedType = v!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
),
|
),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: caliberController,
|
controller: caliberController,
|
||||||
decoration: const InputDecoration(labelText: 'Calibre'),
|
decoration: const InputDecoration(labelText: 'Calibre'),
|
||||||
),
|
)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: opticController,
|
controller: opticController,
|
||||||
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: silencerController,
|
controller: silencerController,
|
||||||
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: triggerController,
|
controller: triggerController,
|
||||||
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||||
),
|
)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
Row(
|
_autoScrollOnFocus(Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -305,12 +352,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: notesController,
|
controller: notesController,
|
||||||
decoration: const InputDecoration(labelText: 'Notes'),
|
decoration: const InputDecoration(labelText: 'Notes'),
|
||||||
maxLines: 2,
|
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 {
|
void _showAddMaintenanceDialog(BuildContext context) async {
|
||||||
final descController = TextEditingController();
|
final descController = TextEditingController();
|
||||||
MaintenanceType selectedType = MaintenanceType.cleaning;
|
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||||
|
DateTime selectedDate = DateTime.now();
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -367,6 +521,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
||||||
maxLines: 2,
|
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: [
|
actions: [
|
||||||
@@ -384,6 +559,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
type: selectedType,
|
type: selectedType,
|
||||||
description: descController.text,
|
description: descController.text,
|
||||||
roundsSinceLast: _totalRounds,
|
roundsSinceLast: _totalRounds,
|
||||||
|
date: selectedDate,
|
||||||
);
|
);
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,26 +76,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
itemCount: _weapons.length,
|
itemCount: _weapons.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final weapon = _weapons[index];
|
final weapon = _weapons[index];
|
||||||
|
final accessories = _accessoryChips(weapon);
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
child: InkWell(
|
||||||
leading: CircleAvatar(
|
borderRadius: BorderRadius.circular(12),
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -104,12 +89,96 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||||
},
|
},
|
||||||
onLongPress: () => _confirmDelete(context, weapon),
|
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 {
|
void _showAddWeaponDialog(BuildContext context) async {
|
||||||
final nameController = TextEditingController();
|
final nameController = TextEditingController();
|
||||||
final caliberController = TextEditingController();
|
final caliberController = TextEditingController();
|
||||||
|
|||||||
@@ -25,12 +25,43 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
List<Session> _recentSessions = [];
|
List<Session> _recentSessions = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
SessionProvider? _sessionProvider;
|
||||||
|
bool _wasSessionActive = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadStats();
|
_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 {
|
Future<void> _loadStats() async {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final stats = await repository.getStatistics();
|
final stats = await repository.getStatistics();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../../data/models/session.dart';
|
|
||||||
import '../../data/models/target_analysis.dart';
|
import '../../data/models/target_analysis.dart';
|
||||||
import '../../data/models/weapon.dart';
|
|
||||||
|
|
||||||
class SessionProvider extends ChangeNotifier {
|
class SessionProvider extends ChangeNotifier {
|
||||||
DateTime? _sessionDate;
|
DateTime? _sessionDate;
|
||||||
|
|||||||
@@ -85,8 +85,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showThemeDialog() {
|
void _showThemeDialog() {
|
||||||
final themeProvider = context.read<ThemeProvider>();
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -347,6 +418,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: '1.0.0',
|
subtitle: '1.0.0',
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
),
|
),
|
||||||
|
_buildSettingsTile(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
title: 'Signaler un bug',
|
||||||
|
subtitle: 'Aidez-nous à corriger les problèmes',
|
||||||
|
onTap: _showReportBugDialog,
|
||||||
|
),
|
||||||
_buildSettingsTile(
|
_buildSettingsTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.privacy_tip_outlined,
|
icon: Icons.privacy_tip_outlined,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
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/models/session.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
import '../../services/statistics_service.dart';
|
import '../../services/statistics_service.dart';
|
||||||
@@ -29,6 +30,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
List<String> _availableWeapons = ['Toutes'];
|
List<String> _availableWeapons = ['Toutes'];
|
||||||
List<String> _availableDistances = ['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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -79,6 +88,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _calculateStats() {
|
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
|
// Filtrer les sessions avant calcul
|
||||||
final filteredSessions = _allSessions.where((s) {
|
final filteredSessions = _allSessions.where((s) {
|
||||||
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
|
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() {
|
List<double> _getScoreHistory() {
|
||||||
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
||||||
// Sort sessions by date and take last 10
|
// 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),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||||
@@ -191,18 +289,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
'Score',
|
'Score',
|
||||||
'${_statistics?.totalScore ?? 0}',
|
'${_statistics?.totalScore ?? 0}',
|
||||||
_getScoreHistory(),
|
_getScoreHistory(),
|
||||||
|
explanations: const [
|
||||||
|
MetricExplanation(
|
||||||
|
'Score',
|
||||||
|
'Total des points marqués sur la période sélectionnée.',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildChartSection(
|
_buildChartSection(
|
||||||
'Précision',
|
'Précision',
|
||||||
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
||||||
_getPrecisionHistory(),
|
_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),
|
const SizedBox(height: 20),
|
||||||
_buildChartSection(
|
_buildChartSection(
|
||||||
'Groupement moyen',
|
'Étalement moyen',
|
||||||
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
|
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
|
||||||
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
|
_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),
|
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(
|
Widget _buildDropdown(
|
||||||
String label,
|
String label,
|
||||||
String value,
|
String value,
|
||||||
@@ -302,8 +516,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
Widget _buildChartSection(
|
Widget _buildChartSection(
|
||||||
String title,
|
String title,
|
||||||
String value,
|
String value,
|
||||||
List<double> dataPoints,
|
List<double> dataPoints, {
|
||||||
) {
|
List<MetricExplanation>? explanations,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -314,9 +529,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
title,
|
children: [
|
||||||
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
|
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),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class ParallelismService {
|
|||||||
|
|
||||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||||
final double nx = gx / magnitude;
|
final double nx = gx / magnitude;
|
||||||
final double ny = gy / magnitude;
|
|
||||||
final double nz = gz / magnitude;
|
final double nz = gz / magnitude;
|
||||||
|
|
||||||
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
||||||
|
|||||||
Reference in New Issue
Block a user