feat: added statistic tile factory
This commit is contained in:
@@ -0,0 +1,318 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:tallee/core/enums.dart';
|
||||||
|
import 'package:tallee/data/models/match.dart';
|
||||||
|
import 'package:tallee/data/models/player.dart';
|
||||||
|
import 'package:tallee/data/models/statistic.dart';
|
||||||
|
import 'package:tallee/presentation/views/main_menu/statistics_view/create_statistic_view.dart'
|
||||||
|
show translateStatisticTypeToString;
|
||||||
|
import 'package:tallee/presentation/widgets/tiles/statistics_tile.dart';
|
||||||
|
|
||||||
|
/// Build the [StatisticsTile] for a given [Statistic].
|
||||||
|
|
||||||
|
Widget buildStatisticTile({
|
||||||
|
required Statistic statistic,
|
||||||
|
required List<Match> matches,
|
||||||
|
required List<Player> players,
|
||||||
|
required BuildContext context,
|
||||||
|
double? width,
|
||||||
|
int itemCount = 5,
|
||||||
|
}) {
|
||||||
|
final filteredMatches = _getFilterMatches(statistic, matches);
|
||||||
|
final filteredPlayers = _getFilteredPlayers(
|
||||||
|
statistic,
|
||||||
|
players,
|
||||||
|
filteredMatches,
|
||||||
|
);
|
||||||
|
|
||||||
|
print('Building tile for statistic: $statistic');
|
||||||
|
print('Filtered matches count: ${filteredMatches.length}');
|
||||||
|
print('Filtered players count: ${filteredPlayers.length}');
|
||||||
|
|
||||||
|
final values = _computeValuesForType(
|
||||||
|
type: statistic.type,
|
||||||
|
matches: filteredMatches,
|
||||||
|
players: filteredPlayers,
|
||||||
|
);
|
||||||
|
print(values);
|
||||||
|
|
||||||
|
return StatisticsTile(
|
||||||
|
icon: _getStatisticIcon(type: statistic.type),
|
||||||
|
title: translateStatisticTypeToString(statistic.type, context),
|
||||||
|
width: width ?? MediaQuery.sizeOf(context).width * 0.95,
|
||||||
|
values: values,
|
||||||
|
itemCount: itemCount,
|
||||||
|
barColor: _getStatisticColor(statistic),
|
||||||
|
statistic: statistic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Match> _getFilterMatches(Statistic statistic, List<Match> matches) {
|
||||||
|
List<Match> filteredMatches = matches;
|
||||||
|
|
||||||
|
// Filter timeframe
|
||||||
|
if (statistic.scopes.contains(StatisticScope.timeframe) &&
|
||||||
|
statistic.timeframe != null) {
|
||||||
|
final minDate = _getMinimumDate(timeframe: statistic.timeframe!);
|
||||||
|
print(
|
||||||
|
'Filtering matches by timeframe: ${statistic.timeframe}, minDate: $minDate',
|
||||||
|
);
|
||||||
|
if (minDate != null) {
|
||||||
|
filteredMatches = matches
|
||||||
|
.where((m) => m.endedAt != null && m.endedAt!.isAfter(minDate))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter games
|
||||||
|
if (statistic.scopes.contains(StatisticScope.selectedGames) &&
|
||||||
|
(statistic.selectedGames?.isNotEmpty ?? false)) {
|
||||||
|
final gameIds = statistic.selectedGames!.map((g) => g.id).toSet();
|
||||||
|
filteredMatches = filteredMatches
|
||||||
|
.where((match) => gameIds.contains(match.game.id))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter groups
|
||||||
|
if (statistic.scopes.contains(StatisticScope.selectedGroups) &&
|
||||||
|
(statistic.selectedGroups?.isNotEmpty ?? false)) {
|
||||||
|
final groupIds = statistic.selectedGroups!.map((g) => g.id).toSet();
|
||||||
|
filteredMatches = filteredMatches
|
||||||
|
.where((m) => m.group != null && groupIds.contains(m.group!.id))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [Player] List with the selected players depending on
|
||||||
|
List<Player> _getFilteredPlayers(
|
||||||
|
Statistic statistic,
|
||||||
|
List<Player> allPlayers,
|
||||||
|
List<Match> filteredMatches,
|
||||||
|
) {
|
||||||
|
// allPlayers
|
||||||
|
if (statistic.scopes.contains(StatisticScope.allPlayers)) {
|
||||||
|
return allPlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedGroups -> only members
|
||||||
|
if (statistic.scopes.contains(StatisticScope.selectedGroups) &&
|
||||||
|
(statistic.selectedGroups?.isNotEmpty ?? false)) {
|
||||||
|
final Set<String> ids = {
|
||||||
|
for (final g in statistic.selectedGroups!)
|
||||||
|
for (final p in g.members) p.id,
|
||||||
|
};
|
||||||
|
return allPlayers.where((p) => ids.contains(p.id)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else -> all players from filtered matches
|
||||||
|
final Set<String> ids = {
|
||||||
|
for (final m in filteredMatches)
|
||||||
|
for (final p in m.players) p.id,
|
||||||
|
};
|
||||||
|
return allPlayers.where((p) => ids.contains(p.id)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [DateTime] with the minimum time and date the [timeframe] allows
|
||||||
|
DateTime? _getMinimumDate({required Timeframe timeframe}) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
switch (timeframe) {
|
||||||
|
case Timeframe.last7Days:
|
||||||
|
return now.subtract(const Duration(days: 7));
|
||||||
|
case Timeframe.last30Days:
|
||||||
|
return now.subtract(const Duration(days: 30));
|
||||||
|
case Timeframe.last90Days:
|
||||||
|
return now.subtract(const Duration(days: 90));
|
||||||
|
case Timeframe.last180Days:
|
||||||
|
return now.subtract(const Duration(days: 180));
|
||||||
|
case Timeframe.lastYear:
|
||||||
|
return now.subtract(const Duration(days: 365));
|
||||||
|
case Timeframe.allTime:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the statistic values for each player based on the statistic type
|
||||||
|
/// and returns a list of (Player, value) tuples sorted descending by value.
|
||||||
|
List<(Player, num)> _computeValuesForType({
|
||||||
|
required StatisticType type,
|
||||||
|
required List<Match> matches,
|
||||||
|
required List<Player> players,
|
||||||
|
}) {
|
||||||
|
switch (type) {
|
||||||
|
case StatisticType.totalMatches:
|
||||||
|
return _sortDesc(
|
||||||
|
players.map((p) => (p, _matchesPlayed(p, matches) as num)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case StatisticType.totalWins:
|
||||||
|
return _sortDesc(
|
||||||
|
players.map((p) => (p, _wins(p, matches) as num)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case StatisticType.totalLosses:
|
||||||
|
return _sortDesc(
|
||||||
|
players
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
(p, (_matchesPlayed(p, matches) - _wins(p, matches)) as num),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case StatisticType.totalScore:
|
||||||
|
return _sortDesc(
|
||||||
|
players.map((p) => (p, _totalScore(p, matches) as num)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case StatisticType.averageScore:
|
||||||
|
return _sortDesc(
|
||||||
|
players.map((p) {
|
||||||
|
final scores = _scoresOf(p, matches);
|
||||||
|
final avg = scores.isEmpty
|
||||||
|
? 0.0
|
||||||
|
: double.parse(
|
||||||
|
(scores.reduce((a, b) => a + b) / scores.length)
|
||||||
|
.toStringAsFixed(2),
|
||||||
|
);
|
||||||
|
return (p, avg as num);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case StatisticType.bestScore:
|
||||||
|
return _sortDesc(
|
||||||
|
players.map((p) {
|
||||||
|
final scores = _scoresOf(p, matches);
|
||||||
|
final best = scores.isEmpty ? 0 : scores.reduce(max);
|
||||||
|
return (p, best as num);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case StatisticType.worstScore:
|
||||||
|
// Ascending here is more meaningful for "worst", but keep the
|
||||||
|
// existing tile semantics (largest bar = top entry) by sorting
|
||||||
|
// descending on the inverse — i.e. show smallest score on top.
|
||||||
|
final entries = players.map((p) {
|
||||||
|
final scores = _scoresOf(p, matches);
|
||||||
|
final worst = scores.isEmpty ? 0 : scores.reduce(min);
|
||||||
|
return (p, worst as num);
|
||||||
|
}).toList();
|
||||||
|
entries.sort((a, b) => a.$2.compareTo(b.$2));
|
||||||
|
return entries;
|
||||||
|
|
||||||
|
case StatisticType.winrate:
|
||||||
|
return _sortDesc(
|
||||||
|
players.map((p) {
|
||||||
|
final played = _matchesPlayed(p, matches);
|
||||||
|
final wins = _wins(p, matches);
|
||||||
|
final rate = played == 0
|
||||||
|
? 0.0
|
||||||
|
: double.parse((wins / played).toStringAsFixed(2));
|
||||||
|
return (p, rate as num);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Helper functions for different statistic types */
|
||||||
|
|
||||||
|
/// Detemerines how many matches the player has played in the given list of matches.
|
||||||
|
int _matchesPlayed(Player p, List<Match> matches) =>
|
||||||
|
matches.where((m) => m.players.any((mp) => mp.id == p.id)).length;
|
||||||
|
|
||||||
|
/// Determines how many matches the player has won in the given list of matches.
|
||||||
|
int _wins(Player p, List<Match> matches) =>
|
||||||
|
matches.where((m) => m.mvp.any((mp) => mp.id == p.id)).length;
|
||||||
|
|
||||||
|
/// Determines the total score of the player in the given list of matches.
|
||||||
|
int _totalScore(Player p, List<Match> matches) {
|
||||||
|
var total = 0;
|
||||||
|
for (final m in matches) {
|
||||||
|
final s = m.scores[p.id];
|
||||||
|
if (s != null) total += s.score;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a list of all scores the player has achieved in the given list of matches.
|
||||||
|
List<int> _scoresOf(Player p, List<Match> matches) => [
|
||||||
|
for (final m in matches)
|
||||||
|
if (m.scores[p.id] != null) m.scores[p.id]!.score,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns the list of entries sorted descending by the statistic value.
|
||||||
|
List<(Player, num)> _sortDesc(List<(Player, num)> entries) {
|
||||||
|
entries.sort((a, b) => b.$2.compareTo(a.$2));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon and color */
|
||||||
|
|
||||||
|
/// Returns the icon for the given statistic type.
|
||||||
|
IconData _getStatisticIcon({required StatisticType type}) {
|
||||||
|
switch (type) {
|
||||||
|
case StatisticType.totalMatches:
|
||||||
|
return Icons.casino;
|
||||||
|
case StatisticType.totalWins:
|
||||||
|
return Icons.emoji_events;
|
||||||
|
case StatisticType.totalLosses:
|
||||||
|
return Icons.sentiment_dissatisfied;
|
||||||
|
case StatisticType.totalScore:
|
||||||
|
return Icons.scoreboard;
|
||||||
|
case StatisticType.averageScore:
|
||||||
|
return Icons.show_chart;
|
||||||
|
case StatisticType.bestScore:
|
||||||
|
return Icons.trending_up;
|
||||||
|
case StatisticType.worstScore:
|
||||||
|
return Icons.trending_down;
|
||||||
|
case StatisticType.winrate:
|
||||||
|
return Icons.percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar colors for the statistic tiles
|
||||||
|
const List<Color> _palette = <Color>[
|
||||||
|
Color(0xFF4CAF50), // green
|
||||||
|
Color(0xFF2196F3), // blue
|
||||||
|
Color(0xFFFF9800), // orange
|
||||||
|
Color(0xFFE91E63), // pink
|
||||||
|
Color(0xFF9C27B0), // purple
|
||||||
|
Color(0xFF00BCD4), // cyan
|
||||||
|
Color(0xFFFFC107), // amber
|
||||||
|
Color(0xFF3F51B5), // indigo
|
||||||
|
Color(0xFF8BC34A), // light green
|
||||||
|
Color(0xFFFF5722), // deep orange
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns a color from the palette based on the statistic's ID as random seed.
|
||||||
|
Color _getStatisticColor(Statistic stat) {
|
||||||
|
final seed = stat.id.hashCode;
|
||||||
|
return _palette[seed.abs() % _palette.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton data */
|
||||||
|
|
||||||
|
/// A placeholder tile with mock data for the loading state.
|
||||||
|
Widget buildSkeletonStatisticTile({required BuildContext context}) {
|
||||||
|
final count = 4 + Random().nextInt(5); // 4..8
|
||||||
|
final values = <(Player, num)>[
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
(Player(name: 'Player ${i + 1}'), count - i),
|
||||||
|
];
|
||||||
|
|
||||||
|
return StatisticsTile(
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
title: 'Skeleton title',
|
||||||
|
width: MediaQuery.sizeOf(context).width * 0.95,
|
||||||
|
values: values,
|
||||||
|
itemCount: count,
|
||||||
|
barColor: _palette[Random().nextInt(_palette.length)],
|
||||||
|
statistic: Statistic(
|
||||||
|
type: StatisticType.totalMatches,
|
||||||
|
scopes: [StatisticScope.allPlayers],
|
||||||
|
timeframe: Timeframe.last7Days,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ import 'package:tallee/data/models/player.dart';
|
|||||||
import 'package:tallee/data/models/statistic.dart';
|
import 'package:tallee/data/models/statistic.dart';
|
||||||
import 'package:tallee/l10n/generated/app_localizations.dart';
|
import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||||
import 'package:tallee/presentation/views/main_menu/statistics_view/create_statistic_view.dart';
|
import 'package:tallee/presentation/views/main_menu/statistics_view/create_statistic_view.dart';
|
||||||
|
import 'package:tallee/presentation/views/main_menu/statistics_view/statistic_tile_factory.dart';
|
||||||
import 'package:tallee/presentation/widgets/app_skeleton.dart';
|
import 'package:tallee/presentation/widgets/app_skeleton.dart';
|
||||||
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
|
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
|
||||||
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
|
|
||||||
|
|
||||||
class StatisticsView extends StatefulWidget {
|
class StatisticsView extends StatefulWidget {
|
||||||
/// A view that displays player statistics
|
/// A view that displays player statistics
|
||||||
@@ -21,36 +21,19 @@ class StatisticsView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _StatisticsViewState extends State<StatisticsView> {
|
class _StatisticsViewState extends State<StatisticsView> {
|
||||||
int matchCount = 0;
|
|
||||||
int groupCount = 0;
|
|
||||||
|
|
||||||
List<(Player, int)> winCounts = List.filled(6, (
|
|
||||||
Player(name: 'Skeleton Player'),
|
|
||||||
1,
|
|
||||||
));
|
|
||||||
List<(Player, int)> matchCounts = List.filled(6, (
|
|
||||||
Player(name: 'Skeleton Player'),
|
|
||||||
1,
|
|
||||||
));
|
|
||||||
List<(Player, double)> winRates = List.filled(6, (
|
|
||||||
Player(name: 'Skeleton Player'),
|
|
||||||
1,
|
|
||||||
));
|
|
||||||
bool isLoading = true;
|
bool isLoading = true;
|
||||||
List<Widget> statisticTiles = List.generate(
|
List<Match> _allMatches = const [];
|
||||||
4,
|
List<Player> _allPlayers = const [];
|
||||||
(_) => const InfoTile(
|
List<Widget> statisticTiles = [];
|
||||||
icon: Icons.sports_score,
|
|
||||||
title: 'Skeleton Statistic',
|
|
||||||
width: double.infinity,
|
|
||||||
content: Text('Skeleton content'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
getStatisticTiles(context);
|
getStatisticTiles(context);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -66,13 +49,14 @@ class _StatisticsViewState extends State<StatisticsView> {
|
|||||||
child: AppSkeleton(
|
child: AppSkeleton(
|
||||||
enabled: isLoading,
|
enabled: isLoading,
|
||||||
fixLayoutBuilder: true,
|
fixLayoutBuilder: true,
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
spacing: 12,
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [...statisticTiles],
|
children: [
|
||||||
),
|
...statisticTiles,
|
||||||
|
SizedBox(height: MediaQuery.paddingOf(context).bottom + 80),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -86,17 +70,16 @@ class _StatisticsViewState extends State<StatisticsView> {
|
|||||||
context,
|
context,
|
||||||
adaptivePageRoute(
|
adaptivePageRoute(
|
||||||
builder: (context) => CreateStatisticView(
|
builder: (context) => CreateStatisticView(
|
||||||
onStatisticCreated: loadStatisticData,
|
onStatisticCreated: () => getStatisticTiles(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final newTile = InfoTile(
|
if (!context.mounted) return;
|
||||||
icon: Icons.sports_score,
|
final newTile = buildStatisticTile(
|
||||||
title: newStatistic.type.name,
|
statistic: newStatistic,
|
||||||
width: MediaQuery.sizeOf(context).width * 0.95,
|
matches: _allMatches,
|
||||||
content: Text(
|
players: _allPlayers,
|
||||||
'${newStatistic.id}\n${newStatistic.scopes}\n${newStatistic.type}\n${newStatistic.timeframe}\n${newStatistic.selectedGroups}\n${newStatistic.selectedGames}\n',
|
context: context,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -111,205 +94,47 @@ class _StatisticsViewState extends State<StatisticsView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads matches and players from the database
|
|
||||||
/// and calculates statistics for each player
|
|
||||||
void loadStatisticData() {
|
|
||||||
final db = Provider.of<AppDatabase>(context, listen: false);
|
|
||||||
|
|
||||||
Future.wait([
|
|
||||||
db.matchDao.getAllMatches(),
|
|
||||||
db.playerDao.getAllPlayers(),
|
|
||||||
db.matchDao.getMatchCount(),
|
|
||||||
db.groupDao.getGroupCount(),
|
|
||||||
Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
|
|
||||||
]).then((results) async {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final matches = results[0] as List<Match>;
|
|
||||||
final players = results[1] as List<Player>;
|
|
||||||
matchCount = results[2] as int;
|
|
||||||
groupCount = results[3] as int;
|
|
||||||
|
|
||||||
winCounts = _calculateWinsForAllPlayers(
|
|
||||||
matches: matches,
|
|
||||||
players: players,
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
matchCounts = _calculateMatchAmountsForAllPlayers(
|
|
||||||
matches: matches,
|
|
||||||
players: players,
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
winRates = computeWinRatePercent(
|
|
||||||
winCounts: winCounts,
|
|
||||||
matchCounts: matchCounts,
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
isLoading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> getStatisticTiles(BuildContext context) async {
|
Future<void> getStatisticTiles(BuildContext context) async {
|
||||||
isLoading = true;
|
|
||||||
final db = Provider.of<AppDatabase>(context, listen: false);
|
|
||||||
final statistics = await db.statisticDao.getAllStatistics();
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
statisticTiles = [];
|
isLoading = true;
|
||||||
for (var statistic in statistics) {
|
statisticTiles = List.generate(
|
||||||
statisticTiles.add(
|
4,
|
||||||
InfoTile(
|
(index) => Column(
|
||||||
icon: Icons.sports_score,
|
children: [
|
||||||
title: statistic.type.name,
|
buildSkeletonStatisticTile(context: context),
|
||||||
width: MediaQuery.sizeOf(context).width * 0.95,
|
const SizedBox(height: 12),
|
||||||
content: Text(statistic.toString()),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
statisticTiles.add(
|
|
||||||
SizedBox(height: MediaQuery.sizeOf(context).height * 0.02),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final db = Provider.of<AppDatabase>(context, listen: false);
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
db.statisticDao.getAllStatistics(),
|
||||||
|
db.matchDao.getAllMatches(),
|
||||||
|
db.playerDao.getAllPlayers(),
|
||||||
|
Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final statistics = results[0] as List<Statistic>;
|
||||||
|
_allMatches = results[1] as List<Match>;
|
||||||
|
_allPlayers = results[2] as List<Player>;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
statisticTiles = [
|
||||||
|
for (final statistic in statistics) ...[
|
||||||
|
buildStatisticTile(
|
||||||
|
statistic: statistic,
|
||||||
|
matches: _allMatches,
|
||||||
|
players: _allPlayers,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculates the number of wins for each player
|
|
||||||
/// and returns a sorted list of tuples (playerName, winCount)
|
|
||||||
List<(Player, int)> _calculateWinsForAllPlayers({
|
|
||||||
required List<Match> matches,
|
|
||||||
required List<Player> players,
|
|
||||||
required BuildContext context,
|
|
||||||
}) {
|
|
||||||
List<(Player, int)> winCounts = [];
|
|
||||||
final loc = AppLocalizations.of(context);
|
|
||||||
|
|
||||||
// Getting the winners
|
|
||||||
for (var match in matches) {
|
|
||||||
final mvps = match.mvp;
|
|
||||||
for (var winner in mvps) {
|
|
||||||
final index = winCounts.indexWhere((entry) => entry.$1.id == winner.id);
|
|
||||||
// -1 means winner not found in winCounts
|
|
||||||
if (index != -1) {
|
|
||||||
final current = winCounts[index].$2;
|
|
||||||
winCounts[index] = (winner, current + 1);
|
|
||||||
} else {
|
|
||||||
winCounts.add((winner, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adding all players with zero wins
|
|
||||||
for (var player in players) {
|
|
||||||
final index = winCounts.indexWhere((entry) => entry.$1.id == player.id);
|
|
||||||
// -1 means player not found in winCounts
|
|
||||||
if (index == -1) {
|
|
||||||
winCounts.add((player, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace player IDs with names
|
|
||||||
for (int i = 0; i < winCounts.length; i++) {
|
|
||||||
final playerId = winCounts[i].$1.id;
|
|
||||||
final player = players.firstWhere(
|
|
||||||
(p) => p.id == playerId,
|
|
||||||
orElse: () => Player(id: playerId, name: loc.not_available),
|
|
||||||
);
|
|
||||||
winCounts[i] = (player, winCounts[i].$2);
|
|
||||||
}
|
|
||||||
|
|
||||||
winCounts.sort((a, b) => b.$2.compareTo(a.$2));
|
|
||||||
|
|
||||||
return winCounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculates the number of matches played for each player
|
|
||||||
/// and returns a sorted list of tuples (playerName, matchCount)
|
|
||||||
List<(Player, int)> _calculateMatchAmountsForAllPlayers({
|
|
||||||
required List<Match> matches,
|
|
||||||
required List<Player> players,
|
|
||||||
required BuildContext context,
|
|
||||||
}) {
|
|
||||||
List<(Player, int)> matchCounts = [];
|
|
||||||
final loc = AppLocalizations.of(context);
|
|
||||||
|
|
||||||
// Counting matches for each player
|
|
||||||
for (var match in matches) {
|
|
||||||
for (Player player in match.players) {
|
|
||||||
// Check if the player is already in matchCounts
|
|
||||||
final index = matchCounts.indexWhere(
|
|
||||||
(entry) => entry.$1.id == player.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// -1 -> not found
|
|
||||||
if (index == -1) {
|
|
||||||
// Add new entry
|
|
||||||
matchCounts.add((player, 1));
|
|
||||||
} else {
|
|
||||||
// Update existing entry
|
|
||||||
final currentMatchAmount = matchCounts[index].$2;
|
|
||||||
matchCounts[index] = (player, currentMatchAmount + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adding all players with zero matches
|
|
||||||
for (var player in players) {
|
|
||||||
final index = matchCounts.indexWhere((entry) => entry.$1.id == player.id);
|
|
||||||
// -1 means player not found in matchCounts
|
|
||||||
if (index == -1) {
|
|
||||||
matchCounts.add((player, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace player IDs with names
|
|
||||||
for (int i = 0; i < matchCounts.length; i++) {
|
|
||||||
final playerId = matchCounts[i].$1.id;
|
|
||||||
final player = players.firstWhere(
|
|
||||||
(p) => p.id == playerId,
|
|
||||||
orElse: () => Player(id: playerId, name: loc.not_available),
|
|
||||||
);
|
|
||||||
matchCounts[i] = (player, matchCounts[i].$2);
|
|
||||||
}
|
|
||||||
|
|
||||||
matchCounts.sort((a, b) => b.$2.compareTo(a.$2));
|
|
||||||
|
|
||||||
return matchCounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<(Player, double)> computeWinRatePercent({
|
|
||||||
required List<(Player, int)> winCounts,
|
|
||||||
required List<(Player, int)> matchCounts,
|
|
||||||
}) {
|
|
||||||
final Map<Player, int> winsMap = {for (var e in winCounts) e.$1: e.$2};
|
|
||||||
final Map<Player, int> matchesMap = {for (var e in matchCounts) e.$1: e.$2};
|
|
||||||
|
|
||||||
// Get all unique player names
|
|
||||||
final player = {...matchesMap.keys};
|
|
||||||
|
|
||||||
// Calculate win rates
|
|
||||||
final result = player.map((name) {
|
|
||||||
final int w = winsMap[name] ?? 0;
|
|
||||||
final int m = matchesMap[name] ?? 0;
|
|
||||||
// Calculate percentage and round to 2 decimal places
|
|
||||||
// Avoid division by zero
|
|
||||||
final double percent = (m > 0)
|
|
||||||
? double.parse(((w / m)).toStringAsFixed(2))
|
|
||||||
: 0;
|
|
||||||
return (name, percent);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Sort the result: first by winrate descending,
|
|
||||||
// then by wins descending in case of a tie
|
|
||||||
result.sort((a, b) {
|
|
||||||
final cmp = b.$2.compareTo(a.$2);
|
|
||||||
if (cmp != 0) return cmp;
|
|
||||||
final wa = winsMap[a.$1] ?? 0;
|
|
||||||
final wb = winsMap[b.$1] ?? 0;
|
|
||||||
return wb.compareTo(wa);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fluttericon/rpg_awesome_icons.dart';
|
||||||
import 'package:tallee/core/common.dart';
|
import 'package:tallee/core/common.dart';
|
||||||
import 'package:tallee/core/custom_theme.dart';
|
import 'package:tallee/core/custom_theme.dart';
|
||||||
|
import 'package:tallee/data/models/game.dart';
|
||||||
|
import 'package:tallee/data/models/group.dart';
|
||||||
import 'package:tallee/data/models/player.dart';
|
import 'package:tallee/data/models/player.dart';
|
||||||
|
import 'package:tallee/data/models/statistic.dart';
|
||||||
import 'package:tallee/l10n/generated/app_localizations.dart';
|
import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||||
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
|
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
required this.values,
|
required this.values,
|
||||||
required this.itemCount,
|
required this.itemCount,
|
||||||
required this.barColor,
|
required this.barColor,
|
||||||
|
required this.statistic,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The icon displayed next to the title.
|
/// The icon displayed next to the title.
|
||||||
@@ -43,6 +48,8 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
/// The color of the bars representing the values.
|
/// The color of the bars representing the values.
|
||||||
final Color barColor;
|
final Color barColor;
|
||||||
|
|
||||||
|
final Statistic statistic;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context);
|
final loc = AppLocalizations.of(context);
|
||||||
@@ -52,7 +59,7 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
title: title,
|
title: title,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
content: Padding(
|
content: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: Visibility(
|
child: Visibility(
|
||||||
visible: values.isNotEmpty,
|
visible: values.isNotEmpty,
|
||||||
replacement: Center(
|
replacement: Center(
|
||||||
@@ -63,7 +70,9 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxBarWidth = constraints.maxWidth * 0.65;
|
final maxBarWidth = constraints.maxWidth * 0.65;
|
||||||
return Column(
|
return Column(
|
||||||
children: List.generate(min(values.length, itemCount), (index) {
|
children: [
|
||||||
|
// Bar chart
|
||||||
|
...List.generate(min(values.length, itemCount), (index) {
|
||||||
/// The maximum wins among all players
|
/// The maximum wins among all players
|
||||||
final maxMatches = values.isNotEmpty ? values[0].$2 : 0;
|
final maxMatches = values.isNotEmpty ? values[0].$2 : 0;
|
||||||
|
|
||||||
@@ -82,6 +91,7 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
|
// Bar
|
||||||
Container(
|
Container(
|
||||||
height: 24,
|
height: 24,
|
||||||
width: barWidth,
|
width: barWidth,
|
||||||
@@ -90,6 +100,8 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
color: barColor,
|
color: barColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Player
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4.0),
|
padding: const EdgeInsets.only(left: 4.0),
|
||||||
child: RichText(
|
child: RichText(
|
||||||
@@ -105,13 +117,14 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: getNameCountText(values[index].$1),
|
text: getNameCountText(
|
||||||
|
values[index].$1,
|
||||||
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: CustomTheme.textColor.withAlpha(
|
color: CustomTheme.textColor
|
||||||
150,
|
.withAlpha(150),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -121,9 +134,12 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
|
// Value
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
values[index].$2 <= 1 && values[index].$2 is double
|
values[index].$2 <= 1 &&
|
||||||
|
values[index].$2 is double
|
||||||
? values[index].$2.toStringAsFixed(2)
|
? values[index].$2.toStringAsFixed(2)
|
||||||
: values[index].$2.toString(),
|
: values[index].$2.toString(),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -137,6 +153,63 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Group & Game info
|
||||||
|
if (statistic.selectedGames != null ||
|
||||||
|
statistic.selectedGroups != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
// Game
|
||||||
|
if (statistic.selectedGames != null &&
|
||||||
|
statistic.selectedGames!.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
RpgAwesome.clovers_card,
|
||||||
|
color: CustomTheme.hintColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
getGameText(statistic.selectedGames!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: CustomTheme.hintColor,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Group
|
||||||
|
if (statistic.selectedGroups != null &&
|
||||||
|
statistic.selectedGroups!.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.groups,
|
||||||
|
color: CustomTheme.hintColor,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
getGroupText(statistic.selectedGroups!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: CustomTheme.hintColor,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -144,4 +217,20 @@ class StatisticsTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getGroupText(List<Group> groups) {
|
||||||
|
var text = groups[0].name;
|
||||||
|
if (groups.length > 1) {
|
||||||
|
return '$text + ${groups.length - 1}';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getGameText(List<Game> games) {
|
||||||
|
var text = games[0].name;
|
||||||
|
if (games.length > 1) {
|
||||||
|
return '$text + ${games.length - 1}';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user