1 Commits

Author SHA1 Message Date
qguillaume
3b2172e3ec feat: integration mlkit Document Scanner pour la prise de photo
Remplace la camera maison par le scanner de documents Google ML Kit
qui detecte les bords de la cible et la redresse de face automatiquement
(mode base = sans filtre couleur, pour conserver les couleurs reelles).

- nouveau service DocumentScannerService (scanTarget)
- capture_screen branche sur le scanner mlkit, reglages galerie 1280/q75 conserves
- AndroidManifest: meta-data com.google.mlkit.vision.DEPENDENCIES=docscanner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:20:04 +02:00
20 changed files with 309 additions and 1865 deletions

View File

@@ -37,6 +37,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- ML Kit Document Scanner : télécharge le module de scan de la cible -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -3,6 +3,7 @@ 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 {

View File

@@ -1,76 +0,0 @@
/// 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'),
),
],
),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/session.dart';
@@ -10,12 +11,6 @@ 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();
@@ -25,9 +20,7 @@ class DatabaseHelper {
}
Future<Database> get database async {
if (_database != null) return _database!;
_initFuture ??= _initDatabase();
_database = await _initFuture!;
_database ??= await _initDatabase();
return _database!;
}
@@ -517,16 +510,6 @@ 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(
@@ -560,6 +543,5 @@ class DatabaseHelper {
final db = await database;
await db.close();
_database = null;
_initFuture = null;
}
}

View File

@@ -182,23 +182,18 @@ 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: date ?? DateTime.now(),
date: DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast,
);
await _databaseHelper.insertMaintenance(entry);

View File

@@ -118,6 +118,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
String? _movingShotId;
@override
void initState() {
@@ -268,7 +269,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
_openImpactEditor(context.read<AnalysisProvider>());
},
child: const Text(
'VALIDER',
'TERMINER',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,

View File

@@ -20,6 +20,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/shot.dart';
import 'analysis_provider.dart';
import 'widgets/target_overlay.dart';

View File

@@ -7,7 +7,6 @@ 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 {
@@ -44,64 +43,31 @@ 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: [
Expanded(
child: _buildStat(
context,
'Étalement',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
_buildStat(
context,
'Diametre',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
Expanded(
child: _buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
_buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
Expanded(
child: _buildStat(
context,
'Décalage',
offsetDescription,
icon: Icons.compare_arrows,
),
_buildStat(
context,
'Decalage',
offsetDescription,
icon: Icons.compare_arrows,
),
],
),
@@ -159,14 +125,12 @@ 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,
),
],
@@ -288,22 +252,22 @@ class GroupingStats extends StatelessWidget {
String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centré';
return 'Centre';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'Haut';
vertical = 'H';
} else if (offsetY > 0.02) {
vertical = 'Bas';
vertical = 'B';
}
if (offsetX < -0.02) {
horizontal = 'Gauche';
horizontal = 'G';
} else if (offsetX > 0.02) {
horizontal = 'Droite';
horizontal = 'D';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {

View File

@@ -7,7 +7,6 @@ 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';
@@ -45,31 +44,6 @@ 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(),
@@ -93,12 +67,11 @@ class ScoreCard extends StatelessWidget {
shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1)
: '-',
subtitle: '/ $maxScore',
),
if (scoreResult != null)
_buildScoreStat(
context,
'Réussite',
'Pourcentage',
'${scoreResult!.percentage.toStringAsFixed(0)}%',
),
],

View File

@@ -4,6 +4,7 @@
/// 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';
@@ -337,41 +338,24 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSignLabel(''),
const SizedBox(width: 12),
Column(
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_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_left, () => _moveCenterByPixels(-1, 0, size)),
const SizedBox(width: 40),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
const SizedBox(width: 12),
_buildSignLabel('+'),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
],
),
);
}
// 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)),

File diff suppressed because it is too large Load Diff

View File

@@ -101,9 +101,24 @@ class _CropScreenState extends State<CropScreen> {
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
// 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É
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
Row(
@@ -111,11 +126,9 @@ class _CropScreenState extends State<CropScreen> {
children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
Text(
'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
@@ -128,36 +141,6 @@ 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°
@@ -255,9 +238,21 @@ class _CropScreenState extends State<CropScreen> {
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
// visée = toute la zone visible → aucun bord noir autour du cadre.
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
// 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;
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition();
@@ -279,7 +274,7 @@ class _CropScreenState extends State<CropScreen> {
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.cover,
fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
),
@@ -350,6 +345,21 @@ 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;
@@ -362,61 +372,6 @@ 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;
@@ -434,13 +389,14 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
final double displayPerSourcePx = math.max(
_viewportSize.width / _imageSize!.width,
_viewportSize.height / _imageSize!.height,
);
// Facteur d'échelle affichage/source (BoxFit.contain) — identique à
// celui utilisé pour afficher l'image dans l'aperçu.
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
final double displayWidth = imageAspect > viewportAspect
? _viewportSize.width
: _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
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements

View File

@@ -19,7 +19,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true;
@override
@@ -33,12 +32,10 @@ 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;
});
}
@@ -96,7 +93,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
Expanded(
child: _buildStatCard(
'Sessions',
_sessionCount.toString(),
'N/A',
Icons.history,
Colors.orange,
),
@@ -152,24 +149,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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 Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
@@ -252,33 +232,6 @@ 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);
@@ -300,41 +253,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_autoScrollOnFocus(TextField(
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
)),
_autoScrollOnFocus(TextField(
),
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!),
),
_autoScrollOnFocus(TextField(
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
)),
),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
_autoScrollOnFocus(TextField(
TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
),
TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
),
TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
_autoScrollOnFocus(Row(
Row(
children: [
Expanded(
child: TextField(
@@ -352,12 +305,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
),
),
],
)),
_autoScrollOnFocus(TextField(
),
TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
)),
),
],
),
),
@@ -391,116 +344,9 @@ 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,
@@ -521,27 +367,6 @@ 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: [
@@ -559,7 +384,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
date: selectedDate,
);
_loadData();
}

View File

@@ -76,11 +76,26 @@ 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: InkWell(
borderRadius: BorderRadius.circular(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)),
],
),
onTap: () async {
await Navigator.push(
context,
@@ -89,96 +104,12 @@ 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();

View File

@@ -25,43 +25,12 @@ 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();

View File

@@ -1,5 +1,7 @@
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;

View File

@@ -85,6 +85,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -245,79 +247,6 @@ 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(
@@ -418,13 +347,6 @@ 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,

View File

@@ -3,7 +3,6 @@ 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';
@@ -30,14 +29,6 @@ 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();
@@ -88,17 +79,6 @@ 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;
@@ -112,80 +92,6 @@ 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
@@ -256,10 +162,6 @@ 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)
@@ -289,46 +191,18 @@ 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(
'Étalement moyen',
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
'Groupement 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),
@@ -354,97 +228,9 @@ 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,
String label,
String value,
List<String> items,
void Function(String?) onChanged,
) {
@@ -516,9 +302,8 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildChartSection(
String title,
String value,
List<double> dataPoints, {
List<MetricExplanation>? explanations,
}) {
List<double> dataPoints,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
@@ -529,17 +314,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
],
],
Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
const SizedBox(height: 10),
Row(

View File

@@ -0,0 +1,54 @@
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
/// Résultat du scan de document.
class DocumentScanResult {
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
final String? imagePath;
/// true si un scan a bien été produit.
bool get success => imagePath != null;
const DocumentScanResult(this.imagePath);
}
/// Service qui lance le scanner de documents Google ML Kit.
///
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
///
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
class DocumentScannerService {
/// Lance le scanner et renvoie le chemin de l'image redressée.
///
/// [pageLimit] est fixé à 1 (une seule cible par scan).
/// On désactive l'import galerie ici car la galerie est gérée séparément.
///
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
/// rehaussement type photocopie) → détruit les couleurs de la cible.
/// - `base` ne fait QUE le recadrage + le redressement de perspective
/// (mise de face), en conservant l'image en couleurs réelles. C'est
/// indispensable pour pouvoir détecter ensuite les impacts.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.base, // redressement seul, sans filtre couleur
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
try {
final DocumentScanningResult result = await scanner.scanDocument();
final images = result.images;
if (images.isEmpty) {
return const DocumentScanResult(null);
}
return DocumentScanResult(images.first);
} finally {
// Libère les ressources natives du scanner.
await scanner.close();
}
}
}

View File

@@ -119,6 +119,7 @@ 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.