Merge branch 'feature/193-statisticsview-rework' into development
Some checks failed
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 40s
Push Pipeline / generate_localizations (push) Successful in 30s
Push Pipeline / test (push) Successful in 1m34s
Push Pipeline / sort_arb_files (push) Failing after 32s
Push Pipeline / format (push) Has been skipped
Push Pipeline / build (push) Has been skipped
Some checks failed
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 40s
Push Pipeline / generate_localizations (push) Successful in 30s
Push Pipeline / test (push) Successful in 1m34s
Push Pipeline / sort_arb_files (push) Failing after 32s
Push Pipeline / format (push) Has been skipped
Push Pipeline / build (push) Has been skipped
# Conflicts: # pubspec.lock # pubspec.yaml
This commit is contained in:
@@ -6,7 +6,7 @@ import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/group_view/group_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/match_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/settings_view/settings_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/statistics_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/statistics_view/statistics_view.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
|
||||
import 'package:tallee/presentation/widgets/navbar_item.dart';
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
|
||||
Expanded(
|
||||
child: PlayerSelection(
|
||||
initialSelectedPlayers: initialSelectedPlayers,
|
||||
onPlayerCreated: () => widget.onMembersChanged?.call(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedPlayers = [...value];
|
||||
@@ -134,6 +135,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
widget.onMembersChanged?.call();
|
||||
await HapticFeedback.successNotification();
|
||||
if (mounted) {
|
||||
Navigator.pop(context, updatedGroup);
|
||||
@@ -157,7 +159,6 @@ class _CreateGroupViewState extends State<CreateGroupView> {
|
||||
final success = await db.groupDao.addGroup(
|
||||
group: Group(name: groupName, members: selectedPlayers),
|
||||
);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ class _GroupViewState extends State<GroupView> {
|
||||
);
|
||||
}
|
||||
return GroupTile(
|
||||
onPlayerChanged: loadGroups,
|
||||
group: groups[index],
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
@@ -106,13 +107,10 @@ class _GroupViewState extends State<GroupView> {
|
||||
context,
|
||||
adaptivePageRoute(
|
||||
builder: (context) {
|
||||
return const CreateGroupView();
|
||||
return CreateGroupView(onMembersChanged: loadGroups);
|
||||
},
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
loadGroups();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -164,7 +164,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
|
||||
game.ruleset,
|
||||
context,
|
||||
),
|
||||
badgeColor: getColorFromGameColor(game.color),
|
||||
badgeColor: getColorFromAppColor(game.color),
|
||||
isHighlighted: selectedGameId == game.id,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
|
||||
@@ -49,10 +49,10 @@ class _CreateGameViewState extends State<CreateGameView> {
|
||||
late final AppDatabase db;
|
||||
|
||||
late List<(Ruleset, String)> _rulesets;
|
||||
late List<(GameColor, String)> _colors;
|
||||
late List<(AppColor, String)> _colors;
|
||||
|
||||
Ruleset? selectedRuleset = Ruleset.singleWinner;
|
||||
GameColor? selectedColor = GameColor.orange;
|
||||
AppColor? selectedColor = AppColor.orange;
|
||||
|
||||
/// Controller for the game name input field.
|
||||
final _gameNameController = TextEditingController();
|
||||
@@ -87,10 +87,10 @@ class _CreateGameViewState extends State<CreateGameView> {
|
||||
),
|
||||
);
|
||||
_colors = List.generate(
|
||||
GameColor.values.length,
|
||||
AppColor.values.length,
|
||||
(index) => (
|
||||
GameColor.values[index],
|
||||
translateGameColorToString(GameColor.values[index], context),
|
||||
AppColor.values[index],
|
||||
translateAppColorToString(AppColor.values[index], context),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -117,7 +117,6 @@ class _CreateGameViewState extends State<CreateGameView> {
|
||||
|
||||
return ScaffoldMessenger(
|
||||
child: Scaffold(
|
||||
backgroundColor: CustomTheme.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: Text(isEditing ? loc.edit_game : loc.create_game),
|
||||
actions: [
|
||||
@@ -468,7 +467,7 @@ class _CreateGameViewState extends State<CreateGameView> {
|
||||
height: 16,
|
||||
margin: const EdgeInsets.only(left: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: getColorFromGameColor(
|
||||
color: getColorFromAppColor(
|
||||
_colors[index].$1,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
@@ -502,13 +501,13 @@ class _CreateGameViewState extends State<CreateGameView> {
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: getColorFromGameColor(selectedColor!),
|
||||
color: getColorFromAppColor(selectedColor!),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: Text(translateGameColorToString(selectedColor!, context)),
|
||||
child: Text(translateAppColorToString(selectedColor!, context)),
|
||||
),
|
||||
Transform.rotate(
|
||||
angle: pi / 2,
|
||||
|
||||
@@ -196,6 +196,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
|
||||
child: PlayerSelection(
|
||||
key: ValueKey(selectedGroup?.id ?? 'no_group'),
|
||||
initialSelectedPlayers: selectedPlayers,
|
||||
onPlayerCreated: () => widget.onMatchesUpdated?.call(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedPlayers = value;
|
||||
|
||||
@@ -39,7 +39,7 @@ class _MatchViewState extends State<MatchView> {
|
||||
game: Game(
|
||||
name: 'Game name',
|
||||
ruleset: Ruleset.singleWinner,
|
||||
color: GameColor.blue,
|
||||
color: AppColor.blue,
|
||||
icon: '',
|
||||
),
|
||||
group: Group(
|
||||
@@ -79,7 +79,7 @@ class _MatchViewState extends State<MatchView> {
|
||||
visible: matches.isNotEmpty,
|
||||
replacement: Center(
|
||||
child: TopCenteredMessage(
|
||||
icon: Icons.report,
|
||||
icon: Icons.info,
|
||||
title: loc.info,
|
||||
message: loc.no_matches_created_yet,
|
||||
),
|
||||
@@ -97,6 +97,7 @@ class _MatchViewState extends State<MatchView> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: MatchTile(
|
||||
onPlayerEdited: loadMatches,
|
||||
width: MediaQuery.sizeOf(context).width * 0.95,
|
||||
onTap: () async {
|
||||
Navigator.push(
|
||||
|
||||
394
lib/presentation/views/main_menu/player_detail_view.dart
Normal file
394
lib/presentation/views/main_menu/player_detail_view.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tallee/core/common.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/match.dart';
|
||||
import 'package:tallee/data/models/player.dart';
|
||||
import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||
import 'package:tallee/presentation/widgets/app_skeleton.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
|
||||
import 'package:tallee/presentation/widgets/colored_icon_container.dart';
|
||||
import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart';
|
||||
import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart';
|
||||
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
|
||||
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
|
||||
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
|
||||
|
||||
class PlayerDetailView extends StatefulWidget {
|
||||
const PlayerDetailView({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.callback,
|
||||
});
|
||||
|
||||
/// The player to display
|
||||
final Player player;
|
||||
|
||||
final VoidCallback callback;
|
||||
|
||||
@override
|
||||
State<PlayerDetailView> createState() => _PlayerDetailViewState();
|
||||
}
|
||||
|
||||
class _PlayerDetailViewState extends State<PlayerDetailView> {
|
||||
late final AppDatabase db;
|
||||
late Player _player;
|
||||
late String playerNameCount;
|
||||
bool isLoading = true;
|
||||
|
||||
/// Total matches played by this player
|
||||
int totalMatches = 0;
|
||||
|
||||
/// Total matches won by this player
|
||||
int matchesWon = 0;
|
||||
|
||||
/// Total groups this player belongs to
|
||||
int totalGroups = 0;
|
||||
|
||||
/// Full list of groups this player belongs to
|
||||
List<Group> playerGroups = List.filled(
|
||||
4,
|
||||
Group(name: 'Skeleton group', members: []),
|
||||
);
|
||||
|
||||
/// Full list of matches this player played in
|
||||
List<Match> playerMatches = List.filled(
|
||||
4,
|
||||
Match(
|
||||
name: 'Skeleton match',
|
||||
game: Game(name: 'Game name', ruleset: Ruleset.singleWinner),
|
||||
players: [],
|
||||
),
|
||||
);
|
||||
|
||||
TextEditingController nameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player = widget.player;
|
||||
db = Provider.of<AppDatabase>(context, listen: false);
|
||||
playerNameCount = getNameCountText(_player);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(loc.player_profile),
|
||||
actions: [
|
||||
HapticIconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () async {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => CustomAlertDialog(
|
||||
title: loc.delete_player,
|
||||
content: Text(loc.this_cannot_be_undone),
|
||||
actions: [
|
||||
CustomDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
text: loc.delete,
|
||||
),
|
||||
CustomDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
buttonType: ButtonType.secondary,
|
||||
text: loc.cancel,
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((confirmed) async {
|
||||
if (confirmed! && context.mounted) {
|
||||
//TODO: implement player deletion in db
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
widget.callback();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 20,
|
||||
bottom: 100,
|
||||
),
|
||||
children: [
|
||||
const Center(
|
||||
child: ColoredIconContainer(
|
||||
icon: Icons.person,
|
||||
containerSize: 55,
|
||||
iconSize: 38,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_player.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CustomTheme.textColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
playerNameCount,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CustomTheme.textColor.withAlpha(120),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(_player.createdAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CustomTheme.textColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
InfoTile(
|
||||
title: '${loc.matches_part_of} ($totalMatches)',
|
||||
icon: Icons.sports_esports,
|
||||
horizontalAlignment: CrossAxisAlignment.start,
|
||||
content: AppSkeleton(
|
||||
enabled: isLoading,
|
||||
fixLayoutBuilder: true,
|
||||
alignment: Alignment.topLeft,
|
||||
child: playerMatches.isNotEmpty
|
||||
? Wrap(
|
||||
alignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: playerMatches.map((match) {
|
||||
return TextIconTile(
|
||||
text: match.name,
|
||||
iconEnabled: false,
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
: Text(
|
||||
loc.no_matches_played_yet,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: CustomTheme.textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
InfoTile(
|
||||
title: '${loc.groups_part_of} ($totalGroups)',
|
||||
icon: Icons.people,
|
||||
horizontalAlignment: CrossAxisAlignment.start,
|
||||
content: AppSkeleton(
|
||||
enabled: isLoading,
|
||||
fixLayoutBuilder: true,
|
||||
alignment: Alignment.topLeft,
|
||||
child: playerGroups.isNotEmpty
|
||||
? Wrap(
|
||||
alignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: playerGroups.map((group) {
|
||||
return TextIconTile(
|
||||
text: group.name,
|
||||
iconEnabled: false,
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
: Text(
|
||||
loc.not_part_of_any_group,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: CustomTheme.textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
InfoTile(
|
||||
title: loc.statistics,
|
||||
icon: Icons.bar_chart,
|
||||
content: AppSkeleton(
|
||||
enabled: isLoading,
|
||||
fixLayoutBuilder: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatRow(
|
||||
loc.matches_played,
|
||||
totalMatches.toString(),
|
||||
),
|
||||
_buildStatRow(loc.matches_won, matchesWon.toString()),
|
||||
_buildStatRow(
|
||||
loc.winrate,
|
||||
'${totalMatches == 0 ? 0 : ((matchesWon / totalMatches) * 100).round()}%',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: MediaQuery.paddingOf(context).bottom,
|
||||
child: MainMenuButton(
|
||||
text: loc.edit_player,
|
||||
icon: Icons.edit,
|
||||
onPressed: () async {
|
||||
nameController.text = _player.name;
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return CustomAlertDialog(
|
||||
title: loc.edit_name,
|
||||
content: TextInputField(
|
||||
controller: nameController,
|
||||
hintText: loc.set_name,
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
actions: [
|
||||
CustomDialogAction(
|
||||
onPressed: isConfirmButtonEnabled()
|
||||
? () => Navigator.of(context).pop(true)
|
||||
: null,
|
||||
text: loc.confirm,
|
||||
),
|
||||
CustomDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
buttonType: ButtonType.secondary,
|
||||
text: loc.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
).then((confirmed) async {
|
||||
if (confirmed! && context.mounted) {
|
||||
final newName = nameController.text.trim();
|
||||
|
||||
if (newName != _player.name) {
|
||||
final fetchedPlayerNameCount = await db.playerDao
|
||||
.getNameCount(name: newName);
|
||||
await db.playerDao.updatePlayerName(
|
||||
playerId: _player.id,
|
||||
name: newName,
|
||||
);
|
||||
widget.callback.call();
|
||||
setState(() {
|
||||
_player = Player(
|
||||
name: newName,
|
||||
createdAt: _player.createdAt,
|
||||
id: _player.id,
|
||||
nameCount: _player.nameCount,
|
||||
description: _player.description,
|
||||
);
|
||||
|
||||
// If there is already a player with the same name,
|
||||
// the count of that player is 0, so we start counting from 2 to get the correct count for this player. If there are no players with the same name, we just show the name without a count.
|
||||
playerNameCount = fetchedPlayerNameCount == 0
|
||||
? ''
|
||||
: ' #${fetchedPlayerNameCount + 1}';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads statistics for this player
|
||||
Future<void> _loadData() async {
|
||||
isLoading = true;
|
||||
final fetchedMatches = await db.matchDao.getMatchesByPlayer(
|
||||
playerId: _player.id,
|
||||
);
|
||||
final fetchedGroups = await db.groupDao.getGroupsByPlayer(
|
||||
playerId: _player.id,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
playerMatches = fetchedMatches;
|
||||
totalMatches = fetchedMatches.length;
|
||||
matchesWon = fetchedMatches
|
||||
.where((match) => match.mvp.any((mvp) => mvp.id == _player.id))
|
||||
.length;
|
||||
playerGroups = fetchedGroups;
|
||||
totalGroups = fetchedGroups.length;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a single statistic row with a label and value
|
||||
/// - [label]: The label of the statistic
|
||||
/// - [value]: The value of the statistic
|
||||
Widget _buildStatRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: CustomTheme.textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool isConfirmButtonEnabled() {
|
||||
return nameController.text.trim().isNotEmpty;
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.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/l10n/generated/app_localizations.dart';
|
||||
import 'package:tallee/presentation/widgets/app_skeleton.dart';
|
||||
import 'package:tallee/presentation/widgets/tiles/quick_info_tile.dart';
|
||||
import 'package:tallee/presentation/widgets/tiles/statistics_tile.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> {
|
||||
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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadStatisticData();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: AppSkeleton(
|
||||
enabled: isLoading,
|
||||
fixLayoutBuilder: true,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
QuickInfoTile(
|
||||
width: constraints.maxWidth * 0.45,
|
||||
height: constraints.maxHeight * 0.13,
|
||||
title: loc.matches,
|
||||
icon: Icons.groups_rounded,
|
||||
value: matchCount,
|
||||
),
|
||||
SizedBox(width: constraints.maxWidth * 0.05),
|
||||
QuickInfoTile(
|
||||
width: constraints.maxWidth * 0.45,
|
||||
height: constraints.maxHeight * 0.13,
|
||||
title: loc.groups,
|
||||
icon: Icons.groups_rounded,
|
||||
value: groupCount,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: constraints.maxHeight * 0.02),
|
||||
Visibility(
|
||||
visible:
|
||||
winCounts.isEmpty &&
|
||||
matchCounts.isEmpty &&
|
||||
winRates.isEmpty,
|
||||
replacement: Column(
|
||||
children: [
|
||||
StatisticsTile(
|
||||
icon: Icons.sports_score,
|
||||
title: loc.wins,
|
||||
width: constraints.maxWidth * 0.95,
|
||||
values: winCounts,
|
||||
itemCount: 3,
|
||||
barColor: Colors.green,
|
||||
),
|
||||
SizedBox(height: constraints.maxHeight * 0.02),
|
||||
StatisticsTile(
|
||||
icon: Icons.percent,
|
||||
title: loc.winrate,
|
||||
width: constraints.maxWidth * 0.95,
|
||||
values: winRates,
|
||||
itemCount: 5,
|
||||
barColor: Colors.orange[700]!,
|
||||
),
|
||||
SizedBox(height: constraints.maxHeight * 0.02),
|
||||
StatisticsTile(
|
||||
icon: Icons.casino,
|
||||
title: loc.amount_of_matches,
|
||||
width: constraints.maxWidth * 0.95,
|
||||
values: matchCounts,
|
||||
itemCount: 10,
|
||||
barColor: Colors.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TopCenteredMessage(
|
||||
icon: Icons.info,
|
||||
title: loc.info,
|
||||
message: AppLocalizations.of(
|
||||
context,
|
||||
).no_statistics_available,
|
||||
),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user