feat: accessoires en armurerie, comparateur de sessions stats, croix de centrage pixel par pixel

This commit is contained in:
qguillaume
2026-06-19 14:40:19 +02:00
parent 8165d3bab3
commit 9fa3a6f46d
4 changed files with 384 additions and 51 deletions

View File

@@ -337,24 +337,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10), border: Border.all(color: Colors.white10),
), ),
child: Column( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)), _buildSignLabel(''),
Row( const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)), _buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
const SizedBox(width: 40), Row(
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)), mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
const SizedBox(width: 40),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
], ],
), ),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)), const SizedBox(width: 12),
_buildSignLabel('+'),
], ],
), ),
); );
} }
// Symbole purement décoratif affiché de part et d'autre de la croix.
Widget _buildSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) { Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container( return Container(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),

View File

@@ -101,6 +101,33 @@ class _CropScreenState extends State<CropScreen> {
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column( : Column(
children: [ 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 // 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 // (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 // est récupérable en déplaçant/zoomant. La sortie d'analyse reste
@@ -126,30 +153,10 @@ class _CropScreenState extends State<CropScreen> {
), ),
), ),
// TEXTE D'AIDE AJUSTÉ const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), // CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
child: Column( _buildDirectionalPad(),
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: 16), const SizedBox(height: 16),
@@ -355,6 +362,61 @@ class _CropScreenState extends State<CropScreen> {
} }
} }
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
// Les symboles « » et « + » de part et d'autre sont purement décoratifs.
Widget _buildDirectionalPad() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildCropSignLabel(''),
const SizedBox(width: 10),
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)),
const SizedBox(width: 28),
_buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)),
],
),
_buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)),
],
),
const SizedBox(width: 10),
_buildCropSignLabel('+'),
],
);
}
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 22),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
);
}
Widget _buildCropSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
);
}
void _nudge(double dx, double dy) {
setState(() {
_offset = _offset + Offset(dx, dy);
});
}
void _onScaleStart(ScaleStartDetails details) { void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale; _baseScale = _scale;
_startFocalPoint = details.focalPoint; _startFocalPoint = details.focalPoint;

View File

@@ -76,26 +76,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
itemCount: _weapons.length, itemCount: _weapons.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final weapon = _weapons[index]; final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
child: ListTile( child: InkWell(
leading: CircleAvatar( borderRadius: BorderRadius.circular(12),
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
context, context,
@@ -104,12 +89,96 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
_loadWeapons(); // Reload in case it was edited or maintenance was added _loadWeapons(); // Reload in case it was edited or maintenance was added
}, },
onLongPress: () => _confirmDelete(context, weapon), onLongPress: () => _confirmDelete(context, weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
), ),
); );
}, },
); );
} }
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
}
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
items.add((Icons.volume_off, weapon.silencer!));
}
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
items.add((Icons.touch_app, weapon.trigger!));
}
return items.map((item) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
],
),
);
}).toList();
}
void _showAddWeaponDialog(BuildContext context) async { void _showAddWeaponDialog(BuildContext context) async {
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();

View File

@@ -30,6 +30,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> _availableWeapons = ['Toutes']; List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes']; List<String> _availableDistances = ['Toutes'];
// --- Comparateur de sessions ---
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
// un switch permet d'alterner l'affichage des stats entre la session A et B.
Session? _compareA;
Session? _compareB;
bool _showingB = false; // false = on affiche A, true = on affiche B
bool get _compareMode => _compareA != null && _compareB != null;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -80,6 +88,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
} }
void _calculateStats() { void _calculateStats() {
// Mode comparaison : on calcule les stats sur la seule session active
// (A ou B selon le switch), sans filtre de période.
if (_compareMode) {
final active = _showingB ? _compareB! : _compareA!;
_statistics = _statisticsService.calculateStatistics(
[active],
period: StatsPeriod.all,
);
return;
}
// Filtrer les sessions avant calcul // Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) { final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon; final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
@@ -93,6 +112,80 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
String _sessionLabel(Session s) {
final d = s.createdAt;
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
return '${s.weapon}$date${s.totalScore} pts';
}
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
Future<void> _openCompareDialog() async {
if (_allSessions.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
);
return;
}
Session? a = _compareA ?? _allSessions[0];
Session? b = _compareB ?? _allSessions[1];
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Comparer 2 sessions'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<Session>(
initialValue: a,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session A'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => a = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<Session>(
initialValue: b,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session B'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => b = v),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
],
),
),
);
if (confirmed == true && a != null && b != null) {
setState(() {
_compareA = a;
_compareB = b;
_showingB = false;
_calculateStats();
});
}
}
void _exitCompareMode() {
setState(() {
_compareA = null;
_compareB = null;
_showingB = false;
_calculateStats();
});
}
List<double> _getScoreHistory() { List<double> _getScoreHistory() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0]; if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// Sort sessions by date and take last 10 // Sort sessions by date and take last 10
@@ -163,6 +256,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
// 1bis. COMPARATEUR DE SESSIONS
_buildComparator(),
const SizedBox(height: 20), const SizedBox(height: 20),
// 2. DONNÉES RAPIDES (Tirs et Sessions) // 2. DONNÉES RAPIDES (Tirs et Sessions)
@@ -257,6 +354,94 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
Widget _buildComparator() {
final theme = Theme.of(context);
if (!_compareMode) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openCompareDialog,
icon: const Icon(Icons.compare_arrows),
label: const Text('Comparer 2 sessions'),
),
);
}
final activeColor = const Color(0xFF1A73E8);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
const Spacer(),
IconButton(
tooltip: 'Quitter la comparaison',
icon: const Icon(Icons.close, size: 18),
onPressed: _exitCompareMode,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Switch pour alterner entre les 2 sessions.
Row(
children: [
Expanded(
child: Text(
'A · ${_sessionLabel(_compareA!)}',
style: TextStyle(
fontSize: 12,
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: _showingB,
activeThumbColor: activeColor,
onChanged: (v) => setState(() {
_showingB = v;
_calculateStats();
}),
),
Expanded(
child: Text(
'B · ${_sessionLabel(_compareB!)}',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
'Affichage : session ${_showingB ? 'B' : 'A'}',
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
),
],
),
);
}
Widget _buildDropdown( Widget _buildDropdown(
String label, String label,
String value, String value,