refactor: perf getAllSessions, zéro warning analyze, docs à jour

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
qguillaume
2026-07-04 10:40:25 +02:00
parent 8d5641c0a3
commit cec9838e9b
14 changed files with 229 additions and 229 deletions

View File

@@ -41,10 +41,12 @@ flutter test --coverage
## Features ## Features
### Analyse de cibles de tir ### Capture de cibles de tir
- Support de cibles concentriques (anneaux) et silhouettes - Support de cibles concentriques (anneaux) et silhouettes
- Chargement d'images de cibles depuis la galerie ou la caméra - Chargement d'images de cibles depuis la galerie ou la caméra
- Détection automatique du centre et du rayon de la cible - Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
et indicateur de parallélisme par accéléromètre (pitch/roll)
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
### Calibration des cibles ### Calibration des cibles
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10) - Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
@@ -52,14 +54,11 @@ flutter test --coverage
- Slider global pour redimensionner tous les anneaux proportionnellement - Slider global pour redimensionner tous les anneaux proportionnellement
- Visualisation en temps réel des zones de score - Visualisation en temps réel des zones de score
### Détection d'impacts ### Placement des impacts
- **Ajout manuel** : cliquer sur l'image pour placer un impact - **Éditeur d'impacts plein écran** : tap pour ajouter, tap sur un impact pour
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables l'éditer (score/suppression), appui long pour déplacer, pincer pour zoomer
- Seuil de luminosité - Le placement est entièrement manuel ; le bouton ↻ de l'écran de plotting
- Taille min/max des impacts efface tous les impacts sans toucher à la calibration
- Circularité minimale
- Ratio de remplissage (distingue les trous pleins des cercles vides)
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
### Calcul des scores ### Calcul des scores
- Score automatique basé sur la position de l'impact dans les zones - Score automatique basé sur la position de l'impact dans les zones

View File

@@ -380,17 +380,71 @@ class DatabaseHelper {
limit: limit, limit: limit,
offset: offset, offset: offset,
); );
if (sessionMaps.isEmpty) return [];
final sessions = <Session>[]; // 3 requêtes groupées au lieu d'une cascade par session puis par analyse
for (final sessionMap in sessionMaps) { // (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
final sessionId = sessionMap['id'] as String; // ce qui ralentissait l'accueil/historique/stats au fil des mois.
final session = await getSession(sessionId); final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
if (session != null) { final analysisMaps = await _queryIn(
sessions.add(session); db,
} AppConstants.targetAnalysesTable,
'session_id',
sessionIds,
);
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
final shotMaps = await _queryIn(
db,
AppConstants.shotsTable,
'analysis_id',
analysisIds,
);
final shotsByAnalysis = <String, List<Shot>>{};
for (final map in shotMaps) {
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
.add(Shot.fromMap(map));
} }
return sessions; final analysesBySession = <String, List<TargetAnalysis>>{};
for (final map in analysisMaps) {
final analysis = TargetAnalysis.fromMap(
map,
shotsByAnalysis[map['id'] as String] ?? [],
);
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
}
return sessionMaps
.map((m) =>
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
.toList();
}
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
Future<List<Map<String, Object?>>> _queryIn(
Database db,
String table,
String column,
List<String> values,
) async {
if (values.isEmpty) return [];
const chunkSize = 500;
final results = <Map<String, Object?>>[];
for (var i = 0; i < values.length; i += chunkSize) {
final chunk = values.sublist(
i,
i + chunkSize > values.length ? values.length : i + chunkSize,
);
final placeholders = List.filled(chunk.length, '?').join(',');
results.addAll(await db.query(
table,
where: '$column IN ($placeholders)',
whereArgs: chunk,
));
}
return results;
} }
Future<int> deleteSession(String id) async { Future<int> deleteSession(String id) async {

View File

@@ -12,7 +12,6 @@ import 'package:provider/provider.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 '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../../data/models/shot.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
@@ -26,6 +25,7 @@ import 'widgets/target_overlay.dart';
import 'widgets/target_calibration.dart'; import 'widgets/target_calibration.dart';
import 'widgets/score_card.dart'; import 'widgets/score_card.dart';
import 'widgets/grouping_stats.dart'; import 'widgets/grouping_stats.dart';
import 'widgets/shot_details_sheet.dart';
class AnalysisScreen extends StatelessWidget { class AnalysisScreen extends StatelessWidget {
final String imagePath; final String imagePath;
@@ -364,7 +364,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
widget.cropOffset?.dy ?? 0.0, widget.cropOffset?.dy ?? 0.0,
0.0, 0.0,
) )
..scale(1.0, 1.0)
..rotateZ( ..rotateZ(
(provider.cropRotation) * (provider.cropRotation) *
(math.pi / 180), (math.pi / 180),
@@ -639,7 +638,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
zoomScale: _currentZoomScale, zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation). // Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) => onShotTapped: (shot) =>
_showShotDetails(context, provider, shot), showShotDetailsSheet(context, provider, shot),
), ),
), ),
], ],
@@ -647,80 +646,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
); );
} }
void _showShotDetails(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
),
],
),
),
);
}
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) { void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
showDialog( showDialog(
context: context, context: context,

View File

@@ -23,6 +23,7 @@ import 'package:provider/provider.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';
import 'widgets/shot_details_sheet.dart';
class ImpactEditorScreen extends StatefulWidget { class ImpactEditorScreen extends StatefulWidget {
const ImpactEditorScreen({super.key}); const ImpactEditorScreen({super.key});
@@ -144,7 +145,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
final hit = _hitTestShot(provider, rel); final hit = _hitTestShot(provider, rel);
if (hit != null) { if (hit != null) {
_showShotDetails(context, provider, hit); showShotDetailsSheet(context, provider, hit);
} else { } else {
provider.addShot(rel.dx, rel.dy); provider.addShot(rel.dx, rel.dy);
} }
@@ -192,7 +193,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
// L'ajout et la sélection sont gérés par le // L'ajout et la sélection sont gérés par le
// GestureDetector parent ci-dessus. // GestureDetector parent ci-dessus.
onShotTapped: (shot) => onShotTapped: (shot) =>
_showShotDetails(context, provider, shot), showShotDetailsSheet(context, provider, shot),
), ),
), ),
], ],
@@ -205,79 +206,4 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
), ),
); );
} }
void _showShotDetails(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
),
],
),
),
);
}
} }

View File

@@ -0,0 +1,85 @@
/// Bottom sheet de détails d'un impact : modification du score et suppression.
///
/// Partagée entre l'écran d'analyse (consultation du plotting) et l'éditeur
/// d'impacts plein écran, qui opèrent sur le même AnalysisProvider.
library;
import 'package:flutter/material.dart';
import '../../../data/models/shot.dart';
import '../analysis_provider.dart';
void showShotDetailsSheet(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
),
],
),
),
);
}

View File

@@ -305,7 +305,9 @@ class _CaptureScreenState extends State<CaptureScreen>
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;
@@ -514,10 +516,10 @@ class _CaptureScreenState extends State<CaptureScreen>
height: MediaQuery.of(context).size.width * 0.85, height: MediaQuery.of(context).size.width * 0.85,
child: Stack( child: Stack(
children: [ children: [
_buildCameraCorner(TopLeft: true, color: frameColor), _buildCameraCorner(topLeft: true, color: frameColor),
_buildCameraCorner(TopRight: true, color: frameColor), _buildCameraCorner(topRight: true, color: frameColor),
_buildCameraCorner(BottomLeft: true, color: frameColor), _buildCameraCorner(bottomLeft: true, color: frameColor),
_buildCameraCorner(BottomRight: true, color: frameColor), _buildCameraCorner(bottomRight: true, color: frameColor),
Center( Center(
child: Container( child: Container(
width: 24, width: 24,
@@ -824,32 +826,32 @@ class _CaptureScreenState extends State<CaptureScreen>
// Coins du cadre (inchangés) // Coins du cadre (inchangés)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
Widget _buildCameraCorner({ Widget _buildCameraCorner({
bool TopLeft = false, bool topLeft = false,
bool TopRight = false, bool topRight = false,
bool BottomLeft = false, bool bottomLeft = false,
bool BottomRight = false, bool bottomRight = false,
Color color = const Color(0xFF00FF00), Color color = const Color(0xFF00FF00),
}) { }) {
return Positioned( return Positioned(
top: (TopLeft || TopRight) ? 10 : null, top: (topLeft || topRight) ? 10 : null,
bottom: (BottomLeft || BottomRight) ? 10 : null, bottom: (bottomLeft || bottomRight) ? 10 : null,
left: (TopLeft || BottomLeft) ? 10 : null, left: (topLeft || bottomLeft) ? 10 : null,
right: (TopRight || BottomRight) ? 10 : null, right: (topRight || bottomRight) ? 10 : null,
child: Container( child: Container(
width: 20, width: 20,
height: 20, height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: (TopLeft || TopRight) top: (topLeft || topRight)
? BorderSide(color: color, width: 4) ? BorderSide(color: color, width: 4)
: BorderSide.none, : BorderSide.none,
bottom: (BottomLeft || BottomRight) bottom: (bottomLeft || bottomRight)
? BorderSide(color: color, width: 4) ? BorderSide(color: color, width: 4)
: BorderSide.none, : BorderSide.none,
left: (TopLeft || BottomLeft) left: (topLeft || bottomLeft)
? BorderSide(color: color, width: 4) ? BorderSide(color: color, width: 4)
: BorderSide.none, : BorderSide.none,
right: (TopRight || BottomRight) right: (topRight || bottomRight)
? BorderSide(color: color, width: 4) ? BorderSide(color: color, width: 4)
: BorderSide.none, : BorderSide.none,
), ),

View File

@@ -114,7 +114,7 @@ class _CropScreenState extends State<CropScreen> {
Flexible( Flexible(
child: Text( child: Text(
'Alignez et pivotez la cible sur la croix', 'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500), style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
), ),
), ),
], ],
@@ -122,7 +122,7 @@ class _CropScreenState extends State<CropScreen> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Glissez à un doigt pour déplacer, pincez pour zoomer', 'Glissez à un doigt pour déplacer, pincez pour zoomer',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12), style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
), ),
], ],
), ),
@@ -274,7 +274,7 @@ class _CropScreenState extends State<CropScreen> {
child: Transform( child: Transform(
transform: Matrix4.identity() transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0) ..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale) ..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)), ..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center, alignment: Alignment.center,
child: Image.file( child: Image.file(
@@ -296,14 +296,14 @@ class _CropScreenState extends State<CropScreen> {
child: Container( child: Container(
width: double.infinity, width: double.infinity,
height: 1.5, height: 1.5,
color: const Color(0xFF00FF00).withOpacity(0.6), color: const Color(0xFF00FF00).withValues(alpha: 0.6),
), ),
), ),
Center( Center(
child: Container( child: Container(
width: 1.5, width: 1.5,
height: double.infinity, height: double.infinity,
color: const Color(0xFF00FF00).withOpacity(0.6), color: const Color(0xFF00FF00).withValues(alpha: 0.6),
), ),
), ),
], ],

View File

@@ -52,7 +52,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => _showEditWeaponDialog(context), onPressed: _showEditWeaponDialog,
), ),
], ],
), ),
@@ -74,7 +74,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
), ),
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMaintenanceDialog(context), onPressed: _showAddMaintenanceDialog,
label: const Text('Entretien'), label: const Text('Entretien'),
icon: const Icon(Icons.build), icon: const Icon(Icons.build),
), ),
@@ -166,7 +166,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36), minimumSize: const Size(36, 36),
), ),
onPressed: () => _showEditAccessoriesDialog(context), onPressed: _showEditAccessoriesDialog,
), ),
], ],
), ),
@@ -223,7 +223,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
title: Text(entry.type.displayName), title: Text(entry.type.displayName),
subtitle: Text(entry.description), subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)), trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(context, entry), onLongPress: () => _confirmDeleteMaintenance(entry),
), ),
); );
}, },
@@ -279,7 +279,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
); );
} }
void _showEditWeaponDialog(BuildContext context) async { void _showEditWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(text: _weapon.name); final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber); final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString()); final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
@@ -309,7 +311,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
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, initialValue: 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!),
@@ -383,8 +385,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
customName: customNameController.text.isEmpty ? null : customNameController.text, customName: customNameController.text.isEmpty ? null : customNameController.text,
); );
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon); await repository.updateWeapon(updatedWeapon);
if (!mounted) return;
setState(() { setState(() {
_weapon = updatedWeapon; _weapon = updatedWeapon;
}); });
@@ -395,7 +397,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une // 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 // nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée. // pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog(BuildContext context) async { void _showEditAccessoriesDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final opticController = TextEditingController(text: _weapon.optic); final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer); final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger); final triggerController = TextEditingController(text: _weapon.trigger);
@@ -444,7 +448,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
), ),
); );
if (result != true) return; if (result != true || !mounted) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text; final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text; final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
@@ -456,11 +460,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
newSilencer == _weapon.silencer && newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger; newTrigger == _weapon.trigger;
if (unchanged) { if (unchanged) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')), const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
); );
}
return; return;
} }
@@ -472,7 +474,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)'; newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
} }
final repository = context.read<SessionRepository>();
final newWeapon = await repository.addWeapon( final newWeapon = await repository.addWeapon(
name: _weapon.name, name: _weapon.name,
type: _weapon.type, type: _weapon.type,
@@ -497,7 +498,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
); );
} }
void _showAddMaintenanceDialog(BuildContext context) async { void _showAddMaintenanceDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final descController = TextEditingController(); final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning; MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now(); DateTime selectedDate = DateTime.now();
@@ -511,7 +514,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
DropdownButtonFormField<MaintenanceType>( DropdownButtonFormField<MaintenanceType>(
value: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'), decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
@@ -553,7 +556,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
); );
if (result == true && descController.text.isNotEmpty) { if (result == true && descController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry( await repository.addMaintenanceEntry(
weaponId: _weapon.id, weaponId: _weapon.id,
type: selectedType, type: selectedType,
@@ -561,11 +563,14 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
roundsSinceLast: _totalRounds, roundsSinceLast: _totalRounds,
date: selectedDate, date: selectedDate,
); );
if (!mounted) return;
_loadData(); _loadData();
} }
} }
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async { void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -582,8 +587,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
); );
if (confirmed == true) { if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id); await repository.deleteMaintenanceEntry(entry.id);
if (!mounted) return;
_loadData(); _loadData();
} }
} }

View File

@@ -46,7 +46,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
? _buildEmptyState() ? _buildEmptyState()
: _buildWeaponList(), : _buildWeaponList(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showAddWeaponDialog(context), onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
); );
@@ -62,7 +62,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
const Text('Aucune arme enregistrée'), const Text('Aucune arme enregistrée'),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () => _showAddWeaponDialog(context), onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'), child: const Text('Ajouter ma première arme'),
), ),
], ],
@@ -88,7 +88,7 @@ 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(weapon),
child: Padding( child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires. // La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -179,7 +179,9 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
}).toList(); }).toList();
} }
void _showAddWeaponDialog(BuildContext context) async { void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2'); final magCountController = TextEditingController(text: '2');
@@ -200,7 +202,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'), decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
), ),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
value: selectedType, initialValue: 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!),
@@ -240,7 +242,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
); );
if (result == true && nameController.text.isNotEmpty) { if (result == true && nameController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addWeapon( await repository.addWeapon(
name: nameController.text, name: nameController.text,
type: selectedType, type: selectedType,
@@ -248,11 +249,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
magazineCount: int.tryParse(magCountController.text) ?? 1, magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10, magazineCapacity: int.tryParse(magCapController.text) ?? 10,
); );
if (!mounted) return;
_loadWeapons(); _loadWeapons();
} }
} }
void _confirmDelete(BuildContext context, Weapon weapon) async { void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -269,8 +273,8 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
); );
if (confirmed == true) { if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteWeapon(weapon.id); await repository.deleteWeapon(weapon.id);
if (!mounted) return;
_loadWeapons(); _loadWeapons();
} }
} }

View File

@@ -111,7 +111,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc) onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor, secondary: AppTheme.primaryColor,
), ),
dialogBackgroundColor: Colors.white, dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor), style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
), ),

View File

@@ -168,7 +168,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Column( Column(
children: [ children: [
DropdownButtonFormField<Weapon>( DropdownButtonFormField<Weapon>(
value: _selectedWeapon, initialValue: _selectedWeapon,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Sélectionner une arme', labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield), prefixIcon: const Icon(Icons.shield),

View File

@@ -67,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) setState(() => _isLoadingStats = false); if (mounted) setState(() => _isLoadingStats = false);
} }
} catch (e) { } catch (e) {
print('Erreur lors du chargement des statistiques: $e'); debugPrint('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false); if (mounted) setState(() => _isLoadingStats = false);
} }
} }
@@ -374,7 +374,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)), subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary), secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled, value: _isUploadEnabled,
activeColor: AppTheme.primaryColor, activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1), side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),

View File

@@ -24,7 +24,7 @@ class StatisticsScreen extends StatefulWidget {
class _StatisticsScreenState extends State<StatisticsScreen> { class _StatisticsScreenState extends State<StatisticsScreen> {
final StatisticsService _statisticsService = StatisticsService(); final StatisticsService _statisticsService = StatisticsService();
StatsPeriod _selectedPeriod = StatsPeriod.all; final StatsPeriod _selectedPeriod = StatsPeriod.all;
SessionStatistics? _statistics; SessionStatistics? _statistics;
bool _isLoading = true; bool _isLoading = true;
List<Session> _allSessions = []; List<Session> _allSessions = [];

View File

@@ -1,5 +1,5 @@
name: bully name: bully
description: "A new Flutter project." description: "Application d'analyse de cibles de tir : capture, centrage, plotting manuel des impacts, scores et statistiques."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev