feat: accessoires en armurerie, comparateur de sessions stats, croix de centrage pixel par pixel
This commit is contained in:
@@ -337,24 +337,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
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)),
|
||||
|
||||
@@ -101,6 +101,33 @@ 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.
|
||||
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<CropScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 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<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;
|
||||
|
||||
@@ -76,26 +76,11 @@ 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: 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<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();
|
||||
|
||||
@@ -30,6 +30,14 @@ 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();
|
||||
@@ -80,6 +88,17 @@ 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;
|
||||
@@ -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() {
|
||||
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
||||
// 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),
|
||||
|
||||
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||
@@ -257,9 +354,97 @@ 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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user