From 9fa3a6f46df07f53ac31dd34ed982b00867cfd95 Mon Sep 17 00:00:00 2001 From: qguillaume Date: Fri, 19 Jun 2026 14:40:19 +0200 Subject: [PATCH] feat: accessoires en armurerie, comparateur de sessions stats, croix de centrage pixel par pixel --- .../analysis/widgets/target_calibration.dart | 31 ++- lib/features/crop/crop_screen.dart | 110 +++++++--- lib/features/garage/weapon_list_screen.dart | 105 ++++++++-- .../statistics/statistics_screen.dart | 189 +++++++++++++++++- 4 files changed, 384 insertions(+), 51 deletions(-) diff --git a/lib/features/analysis/widgets/target_calibration.dart b/lib/features/analysis/widgets/target_calibration.dart index 51a1dd25..0cc5b2bd 100644 --- a/lib/features/analysis/widgets/target_calibration.dart +++ b/lib/features/analysis/widgets/target_calibration.dart @@ -337,24 +337,41 @@ class TargetCalibrationState extends State { borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white10), ), - child: Column( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)), - Row( + _buildSignLabel('−'), + const SizedBox(width: 12), + Column( mainAxisSize: MainAxisSize.min, children: [ - _buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)), - const SizedBox(width: 40), - _buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)), + _buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)), + const SizedBox(width: 40), + _buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)), + ], + ), + _buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)), ], ), - _buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)), + const SizedBox(width: 12), + _buildSignLabel('+'), ], ), ); } + // Symbole purement décoratif affiché de part et d'autre de la croix. + Widget _buildSignLabel(String text) { + return Text( + text, + style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold), + ); + } + Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) { return Container( decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)), diff --git a/lib/features/crop/crop_screen.dart b/lib/features/crop/crop_screen.dart index 6993f36e..2b4cd69c 100644 --- a/lib/features/crop/crop_screen.dart +++ b/lib/features/crop/crop_screen.dart @@ -101,6 +101,33 @@ class _CropScreenState extends State { ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) : Column( children: [ + // TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image. + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + 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), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Glissez à un doigt pour déplacer, pincez pour zoomer', + style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12), + ), + ], + ), + ), + // 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 @@ -126,30 +153,10 @@ class _CropScreenState extends State { ), ), - // TEXTE D'AIDE AJUSTÉ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20), - const SizedBox(width: 8), - Text( - 'Alignez et pivotez la cible sur la croix', - style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500), - ), - ], - ), - const SizedBox(height: 4), - Text( - 'Glissez à un doigt pour déplacer, pincez pour zoomer', - style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12), - ), - ], - ), - ), + const SizedBox(height: 8), + + // CROIX DIRECTIONNELLE — déplace la photo pixel par pixel. + _buildDirectionalPad(), const SizedBox(height: 16), @@ -355,6 +362,61 @@ class _CropScreenState extends State { } } + // 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; diff --git a/lib/features/garage/weapon_list_screen.dart b/lib/features/garage/weapon_list_screen.dart index 2cc39264..d697071d 100644 --- a/lib/features/garage/weapon_list_screen.dart +++ b/lib/features/garage/weapon_list_screen.dart @@ -76,26 +76,11 @@ class _WeaponListScreenState extends State { itemCount: _weapons.length, itemBuilder: (context, index) { final weapon = _weapons[index]; + final accessories = _accessoryChips(weapon); return Card( margin: const EdgeInsets.only(bottom: 12), - child: ListTile( - leading: CircleAvatar( - backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1), - child: Icon( - weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click, - color: AppTheme.primaryColor, - ), - ), - title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text('${weapon.type.displayName} • ${weapon.caliber}'), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)), - Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)), - ], - ), + child: InkWell( + borderRadius: BorderRadius.circular(12), onTap: () async { await Navigator.push( context, @@ -104,12 +89,96 @@ class _WeaponListScreenState extends State { _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 _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(); diff --git a/lib/features/statistics/statistics_screen.dart b/lib/features/statistics/statistics_screen.dart index a5e2bc6a..502f416c 100644 --- a/lib/features/statistics/statistics_screen.dart +++ b/lib/features/statistics/statistics_screen.dart @@ -30,6 +30,14 @@ class _StatisticsScreenState extends State { List _availableWeapons = ['Toutes']; List _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(); @@ -80,6 +88,17 @@ class _StatisticsScreenState extends State { } 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; @@ -93,6 +112,80 @@ class _StatisticsScreenState extends State { ); } + 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 _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( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Comparer 2 sessions'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + 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( + 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 _getScoreHistory() { if (_statistics == null || _statistics!.sessions.isEmpty) return [0]; // Sort sessions by date and take last 10 @@ -163,6 +256,10 @@ class _StatisticsScreenState extends State { ), ], ), + const SizedBox(height: 16), + + // 1bis. COMPARATEUR DE SESSIONS + _buildComparator(), const SizedBox(height: 20), // 2. DONNÉES RAPIDES (Tirs et Sessions) @@ -257,9 +354,97 @@ class _StatisticsScreenState extends State { ); } + // 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 items, void Function(String?) onChanged, ) {