diff --git a/assets/schema.json b/assets/schema.json index 1883122..c80915c 100644 --- a/assets/schema.json +++ b/assets/schema.json @@ -88,13 +88,12 @@ ] }, "winner": { - "type": ["string","null"] + "type": ["object","null"] }, "required": [ "id", "createdAt", - "name", - "winner" + "name" ] } ] diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index a211849..18792b5 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -20,13 +20,16 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { result.map((row) async { final group = await db.groupGameDao.getGroupOfGame(gameId: row.id); final players = await db.playerGameDao.getPlayersOfGame(gameId: row.id); + final winner = row.winnerId != null + ? await db.playerDao.getPlayerById(playerId: row.winnerId!) + : null; return Game( id: row.id, name: row.name, group: group, players: players, createdAt: row.createdAt, - winner: row.winnerId, + winner: winner, ); }), ); @@ -45,13 +48,17 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { if (await db.groupGameDao.gameHasGroup(gameId: gameId)) { group = await db.groupGameDao.getGroupOfGame(gameId: gameId); } + Player? winner; + if (result.winnerId != null) { + winner = await db.playerDao.getPlayerById(playerId: result.winnerId!); + } return Game( id: result.id, name: result.name, players: players, group: group, - winner: result.winnerId, + winner: winner, createdAt: result.createdAt, ); } @@ -64,7 +71,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { GameTableCompanion.insert( id: game.id, name: game.name, - winnerId: Value(game.winner), + winnerId: Value(game.winner?.id), createdAt: game.createdAt, ), mode: InsertMode.insertOrReplace, @@ -100,7 +107,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { id: game.id, name: game.name, createdAt: game.createdAt, - winnerId: Value(game.winner), + winnerId: Value(game.winner?.id), ), ) .toList(), diff --git a/lib/data/dto/game.dart b/lib/data/dto/game.dart index 4188bc4..48ef902 100644 --- a/lib/data/dto/game.dart +++ b/lib/data/dto/game.dart @@ -9,7 +9,7 @@ class Game { final String name; final List? players; final Group? group; - final String? winner; + final Player? winner; Game({ String? id, @@ -17,7 +17,7 @@ class Game { required this.name, this.players, this.group, - this.winner = '', + this.winner, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(); @@ -37,7 +37,7 @@ class Game { .toList() : null, group = json['group'] != null ? Group.fromJson(json['group']) : null, - winner = json['winner'] ?? ''; + winner = json['winner'] != null ? Player.fromJson(json['winner']) : null; /// Converts the Game instance to a JSON object. Map toJson() => { @@ -46,6 +46,6 @@ class Game { 'name': name, 'players': players?.map((player) => player.toJson()).toList(), 'group': group?.toJson(), - 'winner': winner, + 'winner': winner?.toJson(), }; } diff --git a/lib/presentation/views/main_menu/statistics_view.dart b/lib/presentation/views/main_menu/statistics_view.dart index 84ccf77..13e9aae 100644 --- a/lib/presentation/views/main_menu/statistics_view.dart +++ b/lib/presentation/views/main_menu/statistics_view.dart @@ -1,10 +1,258 @@ -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 createState() => _StatisticsViewState(); +} + +class _StatisticsViewState extends State { + late Future> _gamesFuture; + late Future> _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(context, listen: false); + _gamesFuture = db.gameDao.getAllGames(); + _playersFuture = db.playerDao.getAllPlayers(); + + Future.wait([_gamesFuture, _playersFuture]).then((results) async { + await Future.delayed(const Duration(milliseconds: 200)); + final games = results[0] as List; + final players = results[1] as List; + 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 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.casino, + 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 games, + List 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); + 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); + 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 games, + List 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); + 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); + 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); + 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 winsMap = {for (var e in wins) e.$1: e.$2}; + final Map 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; } } diff --git a/lib/presentation/widgets/tiles/statistics_tile.dart b/lib/presentation/widgets/tiles/statistics_tile.dart new file mode 100644 index 0000000..279c492 --- /dev/null +++ b/lib/presentation/widgets/tiles/statistics_tile.dart @@ -0,0 +1,100 @@ +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.toString(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + }), + ), + ), + ), + ); + } +} diff --git a/test/db_tests/game_test.dart b/test/db_tests/game_test.dart index 0d33c1e..4cf6982 100644 --- a/test/db_tests/game_test.dart +++ b/test/db_tests/game_test.dart @@ -50,15 +50,18 @@ void main() { name: 'First Test Game', group: testGroup1, players: [testPlayer4, testPlayer5], + winner: testPlayer4, ); testGame2 = Game( name: 'Second Test Game', group: testGroup2, players: [testPlayer1, testPlayer2, testPlayer3], + winner: testPlayer2, ); testGameOnlyPlayers = Game( name: 'Test Game with Players', players: [testPlayer1, testPlayer2, testPlayer3], + winner: testPlayer3, ); testGameOnlyGroup = Game(name: 'Test Game with Group', group: testGroup2); }); @@ -75,9 +78,16 @@ void main() { expect(result.id, testGame1.id); expect(result.name, testGame1.name); - expect(result.winner, testGame1.winner); expect(result.createdAt, testGame1.createdAt); + if (result.winner != null && testGame1.winner != null) { + expect(result.winner!.id, testGame1.winner!.id); + expect(result.winner!.name, testGame1.winner!.name); + expect(result.winner!.createdAt, testGame1.winner!.createdAt); + } else { + expect(result.winner, testGame1.winner); + } + if (result.group != null) { expect(result.group!.members.length, testGroup1.members.length); @@ -123,7 +133,13 @@ void main() { expect(game.id, testGame.id); expect(game.name, testGame.name); expect(game.createdAt, testGame.createdAt); - expect(game.winner, testGame.winner); + if (game.winner != null && testGame.winner != null) { + expect(game.winner!.id, testGame.winner!.id); + expect(game.winner!.name, testGame.winner!.name); + expect(game.winner!.createdAt, testGame.winner!.createdAt); + } else { + expect(game.winner, testGame.winner); + } // Group-Checks if (testGame.group != null) {