Merge remote-tracking branch 'origin/development' into feature/2-gamehistoryview-anpassen

This commit is contained in:
Yannick
2025-11-23 20:07:10 +01:00
86 changed files with 2560 additions and 809 deletions

View File

@@ -49,13 +49,16 @@ class _CreateGroupViewState extends State<CreateGroupView> {
@override
void dispose() {
_groupNameController.dispose();
_searchBarController
.dispose(); // Listener entfernen und Controller aufräumen
_searchBarController.dispose();
super.dispose();
}
void loadPlayerList() {
_allPlayersFuture = db.playerDao.getAllPlayers();
_allPlayersFuture = Future.delayed(
const Duration(milliseconds: 250),
() => db.playerDao.getAllPlayers(),
);
suggestedPlayers = skeletonData;
_allPlayersFuture.then((loadedPlayers) {
setState(() {
loadedPlayers.sort((a, b) => a.name.compareTo(b.name));
@@ -67,19 +70,19 @@ class _CreateGroupViewState extends State<CreateGroupView> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
backgroundColor: CustomTheme.backgroundColor,
scrolledUnderElevation: 0,
title: const Text(
'Create new group',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
centerTitle: true,
scrolledUnderElevation: 0,
title: const Text(
'Create new group',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
body: Column(
centerTitle: true,
),
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
@@ -123,12 +126,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
.trim()
.isNotEmpty,
onTrailingButtonPressed: () async {
addNewPlayerFromSearch(
context: context,
searchBarController: _searchBarController,
db: db,
loadPlayerList: loadPlayerList,
);
addNewPlayerFromSearch(context: context);
},
onChanged: (value) {
setState(() {
@@ -339,48 +337,47 @@ class _CreateGroupViewState extends State<CreateGroupView> {
),
);
}
}
/// Adds a new player to the database from the search bar input.
/// Shows a snackbar indicating success or failure.
/// [context] - BuildContext to show the snackbar.
/// [searchBarController] - TextEditingController of the search bar.
/// [db] - AppDatabase instance to interact with the database.
/// [loadPlayerList] - Function to reload the player list after adding.
void addNewPlayerFromSearch({
required BuildContext context,
required TextEditingController searchBarController,
required AppDatabase db,
required Function loadPlayerList,
}) async {
String playerName = searchBarController.text.trim();
bool success = await db.playerDao.addPlayer(player: Player(name: playerName));
if (!context.mounted) return;
if (success) {
loadPlayerList();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: CustomTheme.boxColor,
content: Center(
child: Text(
'Successfully added player $playerName.',
style: const TextStyle(color: Colors.white),
/// Adds a new player to the database from the search bar input.
/// Shows a snackbar indicating success or failure.
/// [context] - BuildContext to show the snackbar.
void addNewPlayerFromSearch({required BuildContext context}) async {
String playerName = _searchBarController.text.trim();
Player createdPlayer = Player(name: playerName);
bool success = await db.playerDao.addPlayer(player: createdPlayer);
if (!context.mounted) return;
if (success) {
selectedPlayers.add(createdPlayer);
allPlayers.add(createdPlayer);
setState(() {
_searchBarController.clear();
suggestedPlayers = allPlayers.where((player) {
return !selectedPlayers.contains(player);
}).toList();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: CustomTheme.boxColor,
content: Center(
child: Text(
'Successfully added player $playerName.',
style: const TextStyle(color: Colors.white),
),
),
),
),
);
searchBarController.clear();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: CustomTheme.boxColor,
content: Center(
child: Text(
'Could not add player $playerName.',
style: const TextStyle(color: Colors.white),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: CustomTheme.boxColor,
content: Center(
child: Text(
'Could not add player $playerName.',
style: const TextStyle(color: Colors.white),
),
),
),
),
);
);
}
}
}

View File

@@ -17,12 +17,7 @@ class CustomNavigationBar extends StatefulWidget {
class _CustomNavigationBarState extends State<CustomNavigationBar>
with SingleTickerProviderStateMixin {
int currentIndex = 0;
final List<Widget> tabs = [
const HomeView(),
const GameHistoryView(),
const GroupsView(),
const StatisticsView(),
];
int tabKeyCount = 0;
@override
void initState() {
@@ -31,6 +26,22 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
@override
Widget build(BuildContext context) {
// Pretty ugly but works
final List<Widget> tabs = [
KeyedSubtree(key: ValueKey('home_$tabKeyCount'), child: const HomeView()),
KeyedSubtree(
key: ValueKey('games_$tabKeyCount'),
child: const GameHistoryView(),
),
KeyedSubtree(
key: ValueKey('groups_$tabKeyCount'),
child: const GroupsView(),
),
KeyedSubtree(
key: ValueKey('stats_$tabKeyCount'),
child: const StatisticsView(),
),
];
return Scaffold(
appBar: AppBar(
centerTitle: true,
@@ -42,10 +53,15 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
scrolledUnderElevation: 0,
actions: [
IconButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsView()),
),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsView()),
);
setState(() {
tabKeyCount++;
});
},
icon: const Icon(Icons.settings),
),
],
@@ -54,12 +70,14 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
backgroundColor: CustomTheme.backgroundColor,
body: tabs[currentIndex],
extendBody: true,
bottomNavigationBar: Padding(
padding: const EdgeInsets.only(left: 12.0, right: 12.0, bottom: 8.0),
child: Material(
elevation: 10,
borderRadius: BorderRadius.circular(24),
color: CustomTheme.primaryColor,
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.only(bottom: 30),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: CustomTheme.primaryColor,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: SizedBox(

View File

@@ -34,7 +34,10 @@ class _GroupsViewState extends State<GroupsView> {
void initState() {
super.initState();
db = Provider.of<AppDatabase>(context, listen: false);
_allGroupsFuture = db.groupDao.getAllGroups();
_allGroupsFuture = Future.delayed(
const Duration(milliseconds: 250),
() => db.groupDao.getAllGroups(),
);
}
@override
@@ -69,9 +72,9 @@ class _GroupsViewState extends State<GroupsView> {
}
final bool isLoading =
snapshot.connectionState == ConnectionState.waiting;
final List<Group> groups = isLoading
? skeletonData
: (snapshot.data ?? []);
final List<Group> groups =
isLoading ? skeletonData : (snapshot.data ?? [])
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return Skeletonizer(
effect: PulseEffect(
from: Colors.grey[800]!,
@@ -93,7 +96,9 @@ class _GroupsViewState extends State<GroupsView> {
itemCount: groups.length + 1,
itemBuilder: (BuildContext context, int index) {
if (index == groups.length) {
return const SizedBox(height: 60);
return SizedBox(
height: MediaQuery.paddingOf(context).bottom - 20,
);
}
return GroupTile(group: groups[index]);
},
@@ -103,7 +108,7 @@ class _GroupsViewState extends State<GroupsView> {
),
Positioned(
bottom: 80,
bottom: MediaQuery.paddingOf(context).bottom,
child: CustomWidthButton(
text: 'Create Group',
sizeRelativeToWidth: 0.90,

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/data/db/database.dart';
import 'package:game_tracker/data/dto/game.dart';
import 'package:game_tracker/data/dto/group.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/widgets/buttons/quick_create_button.dart';
import 'package:game_tracker/presentation/widgets/tiles/game_tile.dart';
import 'package:game_tracker/presentation/widgets/tiles/info_tile.dart';
@@ -17,23 +20,42 @@ class HomeView extends StatefulWidget {
class _HomeViewState extends State<HomeView> {
late Future<int> _gameCountFuture;
late Future<int> _groupCountFuture;
late Future<List<Game>> _recentGamesFuture;
bool isLoading = true;
late final List<Game> skeletonData = List.filled(
2,
Game(
name: 'Skeleton Game',
group: Group(
name: 'Skeleton Group',
members: [
Player(name: 'Skeleton Player 1'),
Player(name: 'Skeleton Player 2'),
],
),
winner: Player(name: 'Skeleton Player 1'),
),
);
@override
initState() {
super.initState();
final db = Provider.of<AppDatabase>(context, listen: false);
_gameCountFuture = db.gameDao.getGameCount();
_groupCountFuture = db.groupDao.getGroupCount();
_recentGamesFuture = db.gameDao.getAllGames();
Future.wait([_gameCountFuture, _groupCountFuture]).then((_) async {
await Future.delayed(const Duration(milliseconds: 50));
if (mounted) {
setState(() {
isLoading = false;
});
}
});
Future.wait([_gameCountFuture, _groupCountFuture, _recentGamesFuture]).then(
(_) async {
await Future.delayed(const Duration(milliseconds: 250));
if (mounted) {
setState(() {
isLoading = false;
});
}
},
);
}
@override
@@ -48,12 +70,21 @@ class _HomeViewState extends State<HomeView> {
),
enabled: isLoading,
enableSwitchAnimation: true,
switchAnimationConfig: const SwitchAnimationConfig(
duration: Duration(milliseconds: 200),
switchAnimationConfig: SwitchAnimationConfig(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.linear,
switchOutCurve: Curves.linear,
transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder,
layoutBuilder: AnimatedSwitcher.defaultLayoutBuilder,
layoutBuilder:
(Widget? currentChild, List<Widget> previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
),
child: SingleChildScrollView(
child: Column(
@@ -103,32 +134,91 @@ class _HomeViewState extends State<HomeView> {
width: constraints.maxWidth * 0.95,
title: 'Recent Games',
icon: Icons.timer,
content: const Padding(
padding: EdgeInsets.symmetric(horizontal: 40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GameTile(
gameTitle: 'Gamenight',
gameType: 'Cabo',
ruleset: 'Lowest Points',
players: '5 Players',
winner: 'Leonard',
),
Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Divider(),
),
GameTile(
gameTitle: 'Schoolbreak',
gameType: 'Uno',
ruleset: 'Highest Points',
players: 'The Gang',
winner: 'Lina',
),
SizedBox(height: 8),
],
content: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: FutureBuilder(
future: _recentGamesFuture,
builder:
(
BuildContext context,
AsyncSnapshot<List<Game>> snapshot,
) {
if (snapshot.hasError) {
return const Center(
heightFactor: 4,
child: Text(
'Error while loading recent games.',
),
);
}
if (snapshot.connectionState ==
ConnectionState.done &&
(!snapshot.hasData ||
snapshot.data!.isEmpty)) {
return const Center(
heightFactor: 4,
child: Text('No recent games available.'),
);
}
final List<Game> games =
(isLoading
? skeletonData
: (snapshot.data ?? [])
..sort(
(a, b) => b.createdAt.compareTo(
a.createdAt,
),
))
.take(2)
.toList();
if (games.isNotEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GameTile(
gameTitle: games[0].name,
gameType: 'Winner',
ruleset: 'Ruleset',
players: _getPlayerText(games[0]),
winner: games[0].winner == null
? 'Game in progress...'
: games[0].winner!.name,
),
const Padding(
padding: EdgeInsets.symmetric(
vertical: 8.0,
),
child: Divider(),
),
if (games.length > 1) ...[
GameTile(
gameTitle: games[1].name,
gameType: 'Winner',
ruleset: 'Ruleset',
players: _getPlayerText(games[1]),
winner: games[1].winner == null
? 'Game in progress...'
: games[1].winner!.name,
),
const SizedBox(height: 8),
] else ...[
const Center(
heightFactor: 4,
child: Text(
'No second game available.',
),
),
],
],
);
} else {
return const Center(
heightFactor: 4,
child: Text('No recent games available.'),
);
}
},
),
),
),
@@ -189,4 +279,15 @@ class _HomeViewState extends State<HomeView> {
},
);
}
String _getPlayerText(Game game) {
if (game.group == null) {
final playerCount = game.players?.length ?? 0;
return '$playerCount Players';
}
if (game.players == null || game.players!.isEmpty) {
return game.group!.name;
}
return '${game.group!.name} + ${game.players!.length}';
}
}

View File

@@ -1,13 +1,191 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/core/enums.dart';
import 'package:game_tracker/presentation/widgets/tiles/settings_list_tile.dart';
import 'package:game_tracker/services/data_transfer_service.dart';
class SettingsView extends StatelessWidget {
class SettingsView extends StatefulWidget {
const SettingsView({super.key});
@override
State<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: const Center(child: Text('Settings View')),
appBar: AppBar(backgroundColor: CustomTheme.backgroundColor),
backgroundColor: CustomTheme.backgroundColor,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) =>
SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(24, 0, 24, 10),
child: Text(
textAlign: TextAlign.start,
'Menu',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: Text(
textAlign: TextAlign.start,
'Settings',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
SettingsListTile(
title: 'Export data',
icon: Icons.upload_outlined,
suffixWidget: const Icon(Icons.arrow_forward_ios, size: 16),
onPressed: () async {
final String json =
await DataTransferService.getAppDataAsJson(context);
final result = await DataTransferService.exportData(
json,
'game_tracker-data',
);
if (!context.mounted) return;
showExportSnackBar(context: context, result: result);
},
),
SettingsListTile(
title: 'Import data',
icon: Icons.download_outlined,
suffixWidget: const Icon(Icons.arrow_forward_ios, size: 16),
onPressed: () async {
final result = await DataTransferService.importData(
context,
);
if (!context.mounted) return;
showImportSnackBar(context: context, result: result);
},
),
SettingsListTile(
title: 'Delete all data',
icon: Icons.download_outlined,
suffixWidget: const Icon(Icons.arrow_forward_ios, size: 16),
onPressed: () {
showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete all data?'),
content: const Text('This can\'t be undone'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Abbrechen'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Löschen'),
),
],
),
).then((confirmed) {
if (confirmed == true && context.mounted) {
DataTransferService.deleteAllData(context);
showSnackbar(
context: context,
message: 'Daten erfolgreich gelöscht',
);
}
});
},
),
],
),
),
),
);
}
/// Displays a snackbar based on the import result.
///
/// [context] The BuildContext to show the snackbar in.
/// [result] The result of the import operation.
void showImportSnackBar({
required BuildContext context,
required ImportResult result,
}) {
switch (result) {
case ImportResult.success:
showSnackbar(context: context, message: 'Data successfully imported');
case ImportResult.invalidSchema:
showSnackbar(context: context, message: 'Invalid Schema');
case ImportResult.fileReadError:
showSnackbar(context: context, message: 'Error reading file');
case ImportResult.canceled:
showSnackbar(context: context, message: 'Import canceled');
case ImportResult.formatException:
showSnackbar(
context: context,
message: 'Format Exception (see console)',
);
case ImportResult.unknownException:
showSnackbar(
context: context,
message: 'Unknown Exception (see console)',
);
}
}
/// Displays a snackbar based on the export result.
///
/// [context] The BuildContext to show the snackbar in.
/// [result] The result of the export operation.
void showExportSnackBar({
required BuildContext context,
required ExportResult result,
}) {
switch (result) {
case ExportResult.success:
showSnackbar(context: context, message: 'Data successfully exported');
case ExportResult.canceled:
showSnackbar(context: context, message: 'Export canceled');
case ExportResult.unknownException:
showSnackbar(
context: context,
message: 'Unknown Exception (see console)',
);
}
}
/// Displays a snackbar with the given message and optional action.
///
/// [context] The BuildContext to show the snackbar in.
/// [message] The message to display in the snackbar.
/// [duration] The duration for which the snackbar is displayed.
/// [action] An optional callback function to execute when the action button is pressed.
void showSnackbar({
required BuildContext context,
required String message,
Duration duration = const Duration(seconds: 3),
VoidCallback? action,
}) {
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: CustomTheme.onBoxColor,
duration: duration,
action: action != null
? SnackBarAction(label: 'Rückgängig', onPressed: action)
: null,
),
);
}
}

View File

@@ -1,10 +1,262 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:game_tracker/data/db/database.dart';
import 'package:game_tracker/data/dto/game.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/widgets/tiles/statistics_tile.dart';
import 'package:provider/provider.dart';
import 'package:skeletonizer/skeletonizer.dart';
class StatisticsView extends StatelessWidget {
class StatisticsView extends StatefulWidget {
const StatisticsView({super.key});
@override
State<StatisticsView> createState() => _StatisticsViewState();
}
class _StatisticsViewState extends State<StatisticsView> {
late Future<List<Game>> _gamesFuture;
late Future<List<Player>> _playersFuture;
List<(String, int)> winCounts = List.filled(6, ('Skeleton Player', 1));
List<(String, int)> gameCounts = List.filled(6, ('Skeleton Player', 1));
List<(String, double)> winRates = List.filled(6, ('Skeleton Player', 1));
bool isLoading = true;
@override
void initState() {
super.initState();
final db = Provider.of<AppDatabase>(context, listen: false);
_gamesFuture = db.gameDao.getAllGames();
_playersFuture = db.playerDao.getAllPlayers();
Future.wait([_gamesFuture, _playersFuture]).then((results) async {
await Future.delayed(const Duration(milliseconds: 250));
final games = results[0] as List<Game>;
final players = results[1] as List<Player>;
winCounts = _calculateWinsForAllPlayers(games, players);
gameCounts = _calculateGameAmountsForAllPlayers(games, players);
winRates = computeWinRatePercent(wins: winCounts, games: gameCounts);
if (mounted) {
setState(() {
isLoading = false;
});
}
});
}
@override
Widget build(BuildContext context) {
return const Center(child: Text('Statistics View'));
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return SingleChildScrollView(
child: Skeletonizer(
effect: PulseEffect(
from: Colors.grey[800]!,
to: Colors.grey[600]!,
duration: const Duration(milliseconds: 800),
),
enabled: isLoading,
enableSwitchAnimation: true,
switchAnimationConfig: SwitchAnimationConfig(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.linear,
switchOutCurve: Curves.linear,
transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder,
layoutBuilder:
(Widget? currentChild, List<Widget> previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: constraints.maxHeight * 0.01),
StatisticsTile(
icon: Icons.sports_score,
title: 'Wins per Player',
width: constraints.maxWidth * 0.95,
values: winCounts,
itemCount: 3,
barColor: Colors.blue,
),
SizedBox(height: constraints.maxHeight * 0.02),
StatisticsTile(
icon: Icons.percent,
title: 'Winrate per Player',
width: constraints.maxWidth * 0.95,
values: winRates,
itemCount: 5,
barColor: Colors.orange[700]!,
),
SizedBox(height: constraints.maxHeight * 0.02),
StatisticsTile(
icon: Icons.casino,
title: 'Games per Player',
width: constraints.maxWidth * 0.95,
values: gameCounts,
itemCount: 10,
barColor: Colors.green,
),
SizedBox(height: MediaQuery.paddingOf(context).bottom),
],
),
),
),
);
},
);
}
/// Calculates the number of wins for each player
/// and returns a sorted list of tuples (playerName, winCount)
List<(String, int)> _calculateWinsForAllPlayers(
List<Game> games,
List<Player> players,
) {
List<(String, int)> winCounts = [];
// Getting the winners
for (var game in games) {
final winner = game.winner;
if (winner != null) {
final index = winCounts.indexWhere((entry) => entry.$1 == winner.id);
// -1 means winner not found in winCounts
if (index != -1) {
final current = winCounts[index].$2;
winCounts[index] = (winner.id, current + 1);
} else {
winCounts.add((winner.id, 1));
}
}
}
// Adding all players with zero wins
for (var player in players) {
final index = winCounts.indexWhere((entry) => entry.$1 == player.id);
// -1 means player not found in winCounts
if (index == -1) {
winCounts.add((player.id, 0));
}
}
// Replace player IDs with names
for (int i = 0; i < winCounts.length; i++) {
final playerId = winCounts[i].$1;
final player = players.firstWhere(
(p) => p.id == playerId,
orElse: () => Player(id: playerId, name: 'N.a.'),
);
winCounts[i] = (player.name, winCounts[i].$2);
}
winCounts.sort((a, b) => b.$2.compareTo(a.$2));
return winCounts;
}
/// Calculates the number of games played for each player
/// and returns a sorted list of tuples (playerName, gameCount)
List<(String, int)> _calculateGameAmountsForAllPlayers(
List<Game> games,
List<Player> players,
) {
List<(String, int)> gameCounts = [];
// Counting games for each player
for (var game in games) {
if (game.group != null) {
final members = game.group!.members.map((p) => p.id).toList();
for (var playerId in members) {
final index = gameCounts.indexWhere((entry) => entry.$1 == playerId);
// -1 means player not found in gameCounts
if (index != -1) {
final current = gameCounts[index].$2;
gameCounts[index] = (playerId, current + 1);
} else {
gameCounts.add((playerId, 1));
}
}
}
if (game.players != null) {
final members = game.players!.map((p) => p.id).toList();
for (var playerId in members) {
final index = gameCounts.indexWhere((entry) => entry.$1 == playerId);
// -1 means player not found in gameCounts
if (index != -1) {
final current = gameCounts[index].$2;
gameCounts[index] = (playerId, current + 1);
} else {
gameCounts.add((playerId, 1));
}
}
}
}
// Adding all players with zero games
for (var player in players) {
final index = gameCounts.indexWhere((entry) => entry.$1 == player.id);
// -1 means player not found in gameCounts
if (index == -1) {
gameCounts.add((player.id, 0));
}
}
// Replace player IDs with names
for (int i = 0; i < gameCounts.length; i++) {
final playerId = gameCounts[i].$1;
final player = players.firstWhere(
(p) => p.id == playerId,
orElse: () => Player(id: playerId, name: 'N.a.'),
);
gameCounts[i] = (player.name, gameCounts[i].$2);
}
gameCounts.sort((a, b) => b.$2.compareTo(a.$2));
return gameCounts;
}
// dart
List<(String, double)> computeWinRatePercent({
required List<(String, int)> wins,
required List<(String, int)> games,
}) {
final Map<String, int> winsMap = {for (var e in wins) e.$1: e.$2};
final Map<String, int> gamesMap = {for (var e in games) e.$1: e.$2};
// Get all unique player names
final names = {...winsMap.keys, ...gamesMap.keys};
// Calculate win rates
final result = names.map((name) {
final int w = winsMap[name] ?? 0;
final int g = gamesMap[name] ?? 0;
// Calculate percentage and round to 2 decimal places
// Avoid division by zero
final double percent = (g > 0)
? double.parse(((w / g)).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;
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
class SettingsListTile extends StatelessWidget {
final VoidCallback? onPressed;
final IconData icon;
final String title;
final Widget? suffixWidget;
const SettingsListTile({
super.key,
required this.title,
required this.icon,
this.suffixWidget,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.95,
child: GestureDetector(
onTap: onPressed ?? () {},
child: Container(
margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: CustomTheme.primaryColor,
shape: BoxShape.circle,
),
child: Icon(icon, size: 24),
),
const SizedBox(width: 16),
Text(title, style: const TextStyle(fontSize: 18)),
],
),
if (suffixWidget != null)
suffixWidget!
else
const SizedBox.shrink(),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:game_tracker/presentation/widgets/tiles/info_tile.dart';
class StatisticsTile extends StatelessWidget {
const StatisticsTile({
super.key,
required this.icon,
required this.title,
required this.width,
required this.values,
required this.itemCount,
required this.barColor,
});
final IconData icon;
final String title;
final double width;
final List<(String, num)> values;
final int itemCount;
final Color barColor;
@override
Widget build(BuildContext context) {
final maxBarWidth = MediaQuery.of(context).size.width * 0.65;
return InfoTile(
width: width,
title: title,
icon: icon,
content: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Visibility(
visible: values.isNotEmpty,
replacement: const Center(
heightFactor: 4,
child: Text('No data available.'),
),
child: Column(
children: List.generate(min(values.length, itemCount), (index) {
/// The maximum wins among all players
final maxGames = values.isNotEmpty ? values[0].$2 : 0;
/// Fraction of wins
final double fraction = (maxGames > 0)
? (values[index].$2 / maxGames)
: 0.0;
/// Calculated width for current the bar
final double barWidth = maxBarWidth * fraction;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Stack(
children: [
Container(
height: 24,
width: barWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: barColor,
),
),
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
values[index].$1,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const Spacer(),
Center(
child: Text(
values[index].$2 <= 1 && values[index].$2 is double
? values[index].$2.toStringAsFixed(2)
: values[index].$2.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}),
),
),
),
);
}
}