premier app version beta

This commit is contained in:
2026-01-18 13:38:09 +01:00
commit 031d4a4e17
164 changed files with 13698 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import 'session_detail_screen.dart';
import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = [];
bool _isLoading = true;
TargetType? _filterType;
@override
void initState() {
super.initState();
_loadSessions();
}
Future<void> _loadSessions() async {
setState(() => _isLoading = true);
try {
final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions(
targetType: _filterType,
);
if (mounted) {
setState(() {
_sessions = sessions;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur de chargement: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Historique'),
actions: [
PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list),
tooltip: 'Filtrer',
onSelected: (type) {
setState(() => _filterType = type);
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(
value: null,
child: Text('Tous'),
),
...TargetType.values.map((type) => PopupMenuItem(
value: type,
child: Text(type.displayName),
)),
],
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? _buildEmptyState()
: _buildContent(),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Aucune session',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_filterType != null
? 'Aucune session de type ${_filterType!.displayName}'
: 'Commencez par analyser une cible',
style: TextStyle(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildContent() {
return RefreshIndicator(
onRefresh: _loadSessions,
child: CustomScrollView(
slivers: [
// Chart section
if (_sessions.length >= 2)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: HistoryChart(sessions: _sessions),
),
),
// Filter indicator
if (_filterType != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
child: Chip(
label: Text('Filtre: ${_filterType!.displayName}'),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() => _filterType = null);
_loadSessions();
},
),
),
),
// Sessions list
SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final session = _sessions[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SessionListItem(
session: session,
onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session),
),
);
},
childCount: _sessions.length,
),
),
),
],
),
);
}
void _openSessionDetail(Session session) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SessionDetailScreen(session: session),
),
);
_loadSessions(); // Refresh in case session was deleted
}
Future<void> _deleteSession(Session session) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: Text(
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true && mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
_loadSessions();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}
}

View File

@@ -0,0 +1,246 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../analysis/widgets/target_overlay.dart';
import '../analysis/widgets/score_card.dart';
import '../analysis/widgets/grouping_stats.dart';
import '../statistics/statistics_screen.dart';
class SessionDetailScreen extends StatelessWidget {
final Session session;
const SessionDetailScreen({
super.key,
required this.session,
});
@override
Widget build(BuildContext context) {
final scoreCalculator = context.read<ScoreCalculatorService>();
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
final scoreResult = scoreCalculator.calculateScores(
shots: session.shots,
targetType: session.targetType,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
);
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
return Scaffold(
appBar: AppBar(
title: Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StatisticsScreen(singleSession: session),
),
),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _confirmDelete(context),
tooltip: 'Supprimer',
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Target image with overlay
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
if (File(session.imagePath).existsSync())
Image.file(
File(session.imagePath),
fit: BoxFit.contain,
)
else
Container(
color: Colors.grey[200],
child: const Center(
child: Icon(Icons.image_not_supported, size: 64),
),
),
TargetOverlay(
shots: session.shots,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
targetType: session.targetType,
groupingCenterX: session.groupingCenterX,
groupingCenterY: session.groupingCenterY,
groupingDiameter: session.groupingDiameter,
),
],
),
),
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Session info
_buildSessionInfo(context),
const SizedBox(height: 12),
// Score card
ScoreCard(
totalScore: session.totalScore,
shotCount: session.shotCount,
scoreResult: scoreResult,
targetType: session.targetType,
),
const SizedBox(height: 12),
// Grouping stats
if (session.shotCount > 1)
GroupingStats(
groupingResult: groupingResult,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
),
// Notes
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context),
],
],
),
),
],
),
),
);
}
Widget _buildSessionInfo(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Row(
children: [
Icon(
session.targetType == session.targetType
? Icons.track_changes
: Icons.person,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.targetType.displayName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
.format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
);
}
Widget _buildNotesCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notes, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Text(session.notes!),
],
),
),
);
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Voulez-vous vraiment supprimer cette session?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Supprimer',
style: TextStyle(color: AppTheme.errorColor),
),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart';
class HistoryChart extends StatelessWidget {
final List<Session> sessions;
const HistoryChart({
super.key,
required this.sessions,
});
@override
Widget build(BuildContext context) {
if (sessions.length < 2) {
return const SizedBox.shrink();
}
// Sort sessions by date and take last 10
final sortedSessions = List<Session>.from(sessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final displaySessions = sortedSessions.length > 10
? sortedSessions.sublist(sortedSessions.length - 10)
: sortedSessions;
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.show_chart, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Evolution des scores',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 20,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey[300],
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= displaySessions.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
DateFormat('dd/MM').format(displaySessions[index].createdAt),
style: const TextStyle(fontSize: 10),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 20,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey[300]!),
left: BorderSide(color: Colors.grey[300]!),
),
),
minX: 0,
maxX: (displaySessions.length - 1).toDouble(),
minY: 0,
maxY: _getMaxY(displaySessions),
lineBarsData: [
// Score line
LineChartBarData(
spots: displaySessions.asMap().entries.map((entry) {
return FlSpot(
entry.key.toDouble(),
entry.value.totalScore.toDouble(),
);
}).toList(),
isCurved: true,
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withValues(alpha: 0.1),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final session = displaySessions[spot.x.toInt()];
return LineTooltipItem(
'Score: ${session.totalScore}\n${session.shotCount} tirs',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
),
),
const SizedBox(height: 8),
_buildLegend(context, displaySessions),
],
),
),
);
}
double _getMaxY(List<Session> sessions) {
double maxScore = 0;
for (final session in sessions) {
if (session.totalScore > maxScore) {
maxScore = session.totalScore.toDouble();
}
}
return (maxScore * 1.2).ceilToDouble();
}
Widget _buildLegend(BuildContext context, List<Session> displaySessions) {
final avgScore = displaySessions.fold<int>(0, (sum, s) => sum + s.totalScore) /
displaySessions.length;
final trend = displaySessions.length >= 2
? displaySessions.last.totalScore - displaySessions.first.totalScore
: 0;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildLegendItem(
context,
'Moyenne',
avgScore.toStringAsFixed(1),
Icons.analytics,
AppTheme.primaryColor,
),
_buildLegendItem(
context,
'Tendance',
trend >= 0 ? '+$trend' : '$trend',
trend >= 0 ? Icons.trending_up : Icons.trending_down,
trend >= 0 ? AppTheme.successColor : AppTheme.errorColor,
),
_buildLegendItem(
context,
'Sessions',
'${displaySessions.length}',
Icons.list,
Colors.grey,
),
],
);
}
Widget _buildLegendItem(
BuildContext context,
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}

View File

@@ -0,0 +1,148 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart';
import '../../../data/models/target_type.dart';
class SessionListItem extends StatelessWidget {
final Session session;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const SessionListItem({
super.key,
required this.session,
this.onTap,
this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: _buildThumbnail(),
),
),
const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getTargetIcon(),
size: 16,
color: AppTheme.primaryColor,
),
const SizedBox(width: 4),
Text(
session.targetType.displayName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
session.notes!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Score
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
Text(
'${session.shotCount} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
// Delete button
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: Colors.grey,
iconSize: 20,
),
],
),
),
),
);
}
Widget _buildThumbnail() {
final file = File(session.imagePath);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(),
);
}
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
color: Colors.grey[200],
child: Icon(
_getTargetIcon(),
color: Colors.grey[400],
),
);
}
IconData _getTargetIcon() {
switch (session.targetType) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
}