Revert to 4bd2f97
All checks were successful
Pull Request Pipeline / lint (pull_request) Successful in 45s
Pull Request Pipeline / localizations (pull_request) Successful in 29s
Pull Request Pipeline / test (pull_request) Successful in 1m31s

This commit is contained in:
2026-05-25 19:22:59 +02:00
parent 6679a0f942
commit eaf7822732
70 changed files with 6970 additions and 839 deletions

View File

@@ -0,0 +1,635 @@
import 'package:animated_custom_dropdown/custom_dropdown.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.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/statistic.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart';
class CreateStatisticView extends StatefulWidget {
const CreateStatisticView({super.key, required this.onStatisticCreated});
final void Function() onStatisticCreated;
@override
State<CreateStatisticView> createState() => _CreateStatisticViewState();
}
class _CreateStatisticViewState extends State<CreateStatisticView> {
bool isLoading = false;
/* Data loaded from the database */
List<Player> players = [];
List<Game> games = [];
List<Group> groups = [];
/* User selections */
StatisticType? selectedType;
List<StatisticScope> selectedScope = [];
List<Game> selectedGames = [];
List<Player> selectedPlayers = [];
List<Group> selectedGroups = [];
Timeframe? selectedTimeframe;
@override
void initState() {
loadAllData();
super.initState();
}
@override
Widget build(BuildContext context) {
var loc = AppLocalizations.of(context);
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(title: Text(loc.create_statistic)),
body: Stack(
alignment: AlignmentDirectional.center,
children: [
SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 80,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Classifier title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.classifier,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Text(
'description',
textAlign: TextAlign.start,
style: TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
softWrap: true,
),
],
),
),
// Classifier selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<StatisticType>(
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
listItemBuilder:
(context, item, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
translateStatisticTypeToString(item, context),
style: itemStyle,
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerBuilder: (context, selectedType, enabled) => Text(
translateStatisticTypeToString(selectedType, context),
style: headerStyle,
),
hintText: loc.select_a_classifier,
items: StatisticType.values,
decoration: decoration,
onChanged: (value) {
setState(() {
selectedType = value;
});
},
),
),
const SizedBox(height: 10),
// Scope title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.scope,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Text(
'description',
textAlign: TextAlign.start,
style: TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// Scope selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<StatisticScope>.multiSelect(
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: loc.select_a_scope,
items: StatisticScope.values,
decoration: decoration,
listItemBuilder:
(context, scope, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
translateScopeToString(scope, context),
style: itemStyle,
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerListBuilder: (context, selectedItems, enabled) =>
Text(
selectedItems
.map((s) => translateScopeToString(s, context))
.join(', '),
style: headerStyle,
overflow: TextOverflow.ellipsis,
),
onListChanged: (List<StatisticScope> values) {
setState(() {
selectedScope = values;
});
},
),
),
if (selectedScope.contains(StatisticScope.selectedGames)) ...[
const SizedBox(height: 10),
// games title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.games,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
loc.select_the_filtered_games,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// game selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<Game>.multiSelect(
enabled: !isLoading,
disabledDecoration: disabledDecoration,
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: isLoading ? loc.loading : loc.select_a_game,
items: games,
decoration: decoration,
listItemBuilder:
(context, item, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Name
Text(item.name, style: itemStyle),
const SizedBox(width: 12),
// Ruleset
Text(
translateRulesetToString(
item.ruleset,
context,
),
style: hintStyle.copyWith(fontSize: 12),
),
],
),
// Check icon
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerListBuilder: (context, selectedItems, enabled) =>
Text(
selectedItems.map((g) => g.name).join(', '),
style: const TextStyle(
color: CustomTheme.textColor,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
onListChanged: (List<Game> values) {
setState(() {
selectedGames = values;
});
},
),
),
],
if (selectedScope.contains(
StatisticScope.selectedGroups,
)) ...[
const SizedBox(height: 10),
// groups title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.groups,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
loc.select_the_filtered_groups,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// groups selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<Group>.multiSelect(
enabled: !isLoading,
disabledDecoration: disabledDecoration,
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: isLoading ? loc.loading : loc.select_a_group,
items: groups,
decoration: decoration,
listItemBuilder:
(context, item, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name
Text(item.name, style: itemStyle),
const SizedBox(width: 12),
// Ruleset
Text(
' ${item.members.length.toString()} ${loc.members}',
style: hintStyle.copyWith(fontSize: 12),
),
],
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerListBuilder: (context, selectedItems, enabled) =>
Text(
selectedItems.map((g) => g.name).join(', '),
style: headerStyle,
overflow: TextOverflow.ellipsis,
),
onListChanged: (List<Group> groups) {
setState(() {
selectedGroups = groups;
});
},
),
),
],
if (selectedScope.contains(StatisticScope.timeframe)) ...[
const SizedBox(height: 10),
// timeframe title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.timeframe,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
loc.select_the_filtered_timeframe,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// groups selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<Timeframe>(
enabled: !isLoading,
excludeSelected: false,
disabledDecoration: disabledDecoration,
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: isLoading
? loc.loading
: loc.select_a_timeframe,
items: Timeframe.values,
decoration: decoration,
listItemBuilder:
(context, timeframe, isSelected, onItemSelect) =>
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
translateTimeframeToString(
timeframe,
context,
),
style: itemStyle,
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerBuilder: (context, selectedTimeframe, enabled) =>
Text(
translateTimeframeToString(
selectedTimeframe,
context,
),
style: headerStyle,
overflow: TextOverflow.ellipsis,
),
onChanged: (Timeframe? timeframe) {
setState(() {
selectedTimeframe = timeframe;
});
},
),
),
],
],
),
),
// Create statistic button
Positioned(
bottom: MediaQuery.of(context).padding.bottom,
child: AnimatedDialogButton(
buttonConstraints: const BoxConstraints(minWidth: 350),
buttonText: loc.create_statistic,
onPressed: selectedType != null && selectedScope.isNotEmpty
? () => submitStatistic()
: null,
),
),
],
),
),
);
}
CustomDropdownDecoration get decoration => CustomDropdownDecoration(
listItemDecoration: const ListItemDecoration(
selectedIconBorder: BorderSide(color: CustomTheme.primaryColor, width: 1),
selectedIconColor: CustomTheme.primaryColor,
highlightColor: CustomTheme.secondaryColor,
splashColor: Colors.transparent,
selectedColor: CustomTheme.onBoxColor,
),
listItemStyle: itemStyle,
headerStyle: headerStyle,
hintStyle: hintStyle,
closedFillColor: CustomTheme.boxColor,
closedBorder: Border.all(color: CustomTheme.boxBorderColor, width: 1),
expandedFillColor: CustomTheme.boxColor,
expandedBorder: Border.all(color: CustomTheme.boxBorderColor, width: 1),
);
CustomDropdownDisabledDecoration get disabledDecoration =>
CustomDropdownDisabledDecoration(
fillColor: CustomTheme.boxColor.withAlpha(125),
border: Border.all(
color: CustomTheme.boxBorderColor.withAlpha(125),
width: 1,
),
headerStyle: disabledHeaderStyle,
hintStyle: disabledHintStyle,
);
TextStyle get headerStyle => const TextStyle(
color: CustomTheme.textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
);
TextStyle get itemStyle =>
const TextStyle(color: CustomTheme.textColor, fontSize: 14);
TextStyle get hintStyle =>
const TextStyle(color: CustomTheme.hintColor, fontSize: 14);
TextStyle get disabledHeaderStyle => const TextStyle(
color: CustomTheme.hintColor,
fontSize: 14,
fontWeight: FontWeight.bold,
);
TextStyle get disabledHintStyle =>
const TextStyle(color: CustomTheme.hintColor, fontSize: 14);
Future<void> loadAllData() async {
isLoading = true;
final db = Provider.of<AppDatabase>(context, listen: false);
Future.wait([
db.playerDao.getAllPlayers(),
db.groupDao.getAllGroups(),
db.gameDao.getAllGames(),
Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
])
.then((results) async {
players = results[0];
groups = results[1];
games = results[2];
isLoading = false;
})
.catchError((error) {
print('Error loading data: $error');
});
}
void submitStatistic() {
final newStatistic = Statistic(
type: selectedType!,
scopes: selectedScope,
timeframe: selectedTimeframe,
selectedGroups: selectedGroups,
selectedGames: selectedGames,
);
final db = Provider.of<AppDatabase>(context, listen: false);
db.statisticDao.addStatistic(statistic: newStatistic);
Navigator.of(context).pop(newStatistic);
}
}
String translateTimeframeToString(Timeframe timeframe, BuildContext context) {
final loc = AppLocalizations.of(context);
switch (timeframe) {
case Timeframe.last7Days:
return loc.last_7_days;
case Timeframe.last30Days:
return loc.last_30_days;
case Timeframe.last90Days:
return loc.last_90_days;
case Timeframe.last180Days:
return loc.last_180_days;
case Timeframe.lastYear:
return loc.last_year;
case Timeframe.allTime:
return loc.all_time;
}
}
String translateScopeToString(StatisticScope scope, BuildContext context) {
final loc = AppLocalizations.of(context);
switch (scope) {
case StatisticScope.allPlayers:
return loc.all_players;
case StatisticScope.selectedGroups:
return loc.selected_groups;
case StatisticScope.selectedGames:
return loc.selected_games;
case StatisticScope.timeframe:
return loc.timeframe;
}
}
String translateStatisticTypeToString(
StatisticType type,
BuildContext context,
) {
final loc = AppLocalizations.of(context);
switch (type) {
case StatisticType.totalMatches:
return loc.total_matches;
case StatisticType.totalWins:
return loc.total_wins;
case StatisticType.totalScore:
return loc.total_score;
case StatisticType.totalLosses:
return loc.total_losses;
case StatisticType.averageScore:
return loc.average_score;
case StatisticType.bestScore:
return loc.best_score;
case StatisticType.worstScore:
return loc.worst_score;
case StatisticType.winrate:
return loc.winrate;
}
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/data/db/database.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/presentation/views/main_menu/statistics_view/create_statistic_view.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/statistics_tile.dart';
class StatisticDetailView extends StatefulWidget {
const StatisticDetailView({
super.key,
required this.statistic,
required this.values,
required this.icon,
required this.barColor,
});
final Statistic statistic;
final List<(Player, num)> values;
final IconData icon;
final Color barColor;
@override
State<StatisticDetailView> createState() => _StatisticDetailViewState();
}
class _StatisticDetailViewState extends State<StatisticDetailView> {
late int displayCount;
@override
void initState() {
super.initState();
displayCount = widget.statistic.displayCount;
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final title = translateStatisticTypeToString(
widget.statistic.type,
context,
);
const style = TextStyle(fontWeight: FontWeight.bold);
return Scaffold(
appBar: AppBar(
title: Text(title),
leading: HapticIconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () => handleBack(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
StatisticsTile(
icon: widget.icon,
title: title,
width: MediaQuery.sizeOf(context).width * 0.95,
values: widget.values,
barColor: widget.barColor,
selectedGroups: widget.statistic.selectedGroups,
selectedGames: widget.statistic.selectedGames,
displayCount: displayCount,
showAllValues: true,
),
const SizedBox(height: 12),
InfoTile(
icon: Icons.filter_alt,
title: loc.filter,
content: Column(
spacing: 12,
children: [
// Scopes
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.scope, style: style),
Text(
widget.statistic.scopes
.map(
(scope) => translateScopeToString(scope, context),
)
.join('\n'),
textAlign: TextAlign.end,
),
],
),
// Timeframe
if (widget.statistic.timeframe != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.timeframe, style: style),
Text(
translateTimeframeToString(
widget.statistic.timeframe!,
context,
),
textAlign: TextAlign.end,
),
],
),
// Groups
if (widget.statistic.selectedGroups != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.groups, style: style),
Text(
widget.statistic.selectedGroups!
.map((group) => group.name)
.join('\n'),
textAlign: TextAlign.end,
),
],
),
// Games
if (widget.statistic.selectedGames != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.games, style: style),
Text(
widget.statistic.selectedGames!
.map((game) => game.name)
.join('\n'),
textAlign: TextAlign.end,
),
],
),
if (widget.values.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.displayed_entries, style: style),
Row(
children: [
HapticIconButton(
icon: const Icon(Icons.remove),
onPressed: displayCount <= 1
? null
: () => setState(() => displayCount -= 1),
),
SizedBox(
width: 30,
child: Text(
'$displayCount',
textAlign: TextAlign.center,
),
),
HapticIconButton(
icon: const Icon(Icons.add),
onPressed: displayCount >= widget.values.length
? null
: () => setState(() => displayCount += 1),
),
],
),
],
),
],
),
),
],
),
),
);
}
// Handles saving the display count and giving it to statistics view
Future<void> handleBack(BuildContext context) async {
final db = Provider.of<AppDatabase>(context, listen: false);
await db.statisticDao.updateDisplayCount(widget.statistic.id, displayCount);
if (context.mounted) Navigator.of(context).pop(displayCount);
}
}

View File

@@ -0,0 +1,322 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.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';
List<Color> _colorPalette = AppColor.values
.map((c) => getColorFromAppColor(c))
.toList();
/// Returns the icon for the given statistic type.
IconData getStatisticIconForType(StatisticType type) =>
_getStatisticIcon(type: type);
/// Returns a color from the palette based on the statistic's ID.
Color getStatisticColorForStatistic(Statistic stat) => _getStatisticColor(stat);
/// Computes the statistic values for a given [Statistic].
List<(Player, num)> computeStatisticValues({
required Statistic statistic,
required List<Match> matches,
required List<Player> players,
}) {
final filteredMatches = _getFilterMatches(statistic, matches);
final filteredPlayers = _getFilteredPlayers(
statistic,
players,
filteredMatches,
);
return _computeValuesForType(
type: statistic.type,
matches: filteredMatches,
players: filteredPlayers,
);
}
/// 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,
}) {
final values = computeStatisticValues(
statistic: statistic,
matches: matches,
players: players,
);
return StatisticsTile(
icon: _getStatisticIcon(type: statistic.type),
title: translateStatisticTypeToString(statistic.type, context),
width: width ?? MediaQuery.sizeOf(context).width * 0.95,
values: values,
barColor: _getStatisticColor(statistic),
displayCount: statistic.displayCount,
selectedGroups: statistic.selectedGroups,
selectedGames: statistic.selectedGames,
);
}
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;
}
}
/// 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 _colorPalette[seed.abs() % _colorPalette.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,
barColor: _colorPalette[Random().nextInt(_colorPalette.length)],
selectedGames: [Game(name: 'Game 1', ruleset: Ruleset.highestScore)],
selectedGroups: [Group(name: 'Group 1', members: [])],
displayCount: 5,
);
}

View File

@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/data/db/database.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/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/statistic_detail_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/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/top_centered_message.dart';
class StatisticsView extends StatefulWidget {
/// A view that displays player statistics
const StatisticsView({super.key});
@override
State<StatisticsView> createState() => _StatisticsViewState();
}
class _StatisticsViewState extends State<StatisticsView> {
bool isLoading = true;
List<Match> _allMatches = const [];
List<Player> _allPlayers = const [];
List<Statistic> _statistics = const [];
List<Widget> statisticTiles = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
loadStatistics(context);
});
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Stack(
alignment: AlignmentDirectional.bottomCenter,
fit: StackFit.expand,
children: [
Visibility(
visible: statisticTiles.isNotEmpty,
replacement: Center(
child: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: loc.no_statistics_created_yet,
),
),
child: SingleChildScrollView(
child: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...statisticTiles,
SizedBox(
height: MediaQuery.paddingOf(context).bottom + 80,
),
],
),
),
),
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 20,
child: MainMenuButton(
text: loc.create_statistic,
icon: Icons.bar_chart,
onPressed: () async {
Statistic newStatistic = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateStatisticView(
onStatisticCreated: () => loadStatistics(context),
),
),
);
if (!context.mounted) return;
setState(() {
_statistics = [..._statistics, newStatistic];
statisticTiles = _statistics
.map((stat) => _buildStatisticTile(context, stat))
.toList();
});
},
),
),
],
);
},
);
}
Future<void> loadStatistics(BuildContext context) async {
setState(() {
isLoading = true;
statisticTiles = List.generate(
4,
(index) => Column(
children: [
buildSkeletonStatisticTile(context: context),
const SizedBox(height: 12),
],
),
);
});
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>;
_statistics = statistics;
setState(() {
statisticTiles = _statistics
.map((stat) => _buildStatisticTile(context, stat))
.toList();
isLoading = false;
});
}
Widget _buildStatisticTile(BuildContext context, Statistic statistic) {
final values = computeStatisticValues(
statistic: statistic,
matches: _allMatches,
players: _allPlayers,
);
return GestureDetector(
onTap: () async {
final newDisplayCount = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => StatisticDetailView(
statistic: statistic,
values: values,
icon: getStatisticIconForType(statistic.type),
barColor: getStatisticColorForStatistic(statistic),
),
),
);
if (newDisplayCount != null &&
newDisplayCount != statistic.displayCount) {
setState(() {
_statistics = _statistics
.map(
(stat) => stat.id == statistic.id
? stat.copyWith(displayCount: newDisplayCount)
: stat,
)
.toList();
statisticTiles = _statistics
.map((stat) => _buildStatisticTile(context, stat))
.toList();
});
}
},
child: buildStatisticTile(
statistic: statistic,
matches: _allMatches,
players: _allPlayers,
context: context,
),
);
}
}