diff --git a/lib/data/dao/group_dao.dart b/lib/data/dao/group_dao.dart index bffe5a4..2de2ab9 100644 --- a/lib/data/dao/group_dao.dart +++ b/lib/data/dao/group_dao.dart @@ -185,6 +185,38 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { return count ?? 0; } + /// Retrieves all groups a specific player belongs to. + /// Returns an empty list if the player is not part of any group. + Future> getGroupsByPlayer({required String playerId}) async { + final playerGroups = await (select( + playerGroupTable, + )..where((pg) => pg.playerId.equals(playerId))).get(); + + if (playerGroups.isEmpty) return []; + + final groupIds = playerGroups.map((pg) => pg.groupId).toSet().toList(); + final rows = + await (select(groupTable) + ..where((g) => g.id.isIn(groupIds)) + ..orderBy([(g) => OrderingTerm.desc(g.createdAt)])) + .get(); + + return Future.wait( + rows.map((groupData) async { + final members = await db.playerGroupDao.getPlayersOfGroup( + groupId: groupData.id, + ); + return Group( + id: groupData.id, + name: groupData.name, + description: groupData.description, + members: members, + createdAt: groupData.createdAt, + ); + }), + ); + } + /// Checks if a group with the given [groupId] exists in the database. /// Returns `true` if the group exists, `false` otherwise. Future groupExists({required String groupId}) async { diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 88cca35..e8414f4 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -352,6 +352,53 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { return count ?? 0; } + Future> getMatchesByPlayer({required String playerId}) async { + final playerMatches = await (select( + playerMatchTable, + )..where((pm) => pm.playerId.equals(playerId))).get(); + + if (playerMatches.isEmpty) return []; + + final matchIds = playerMatches.map((pm) => pm.matchId).toSet().toList(); + final rows = + await (select(matchTable) + ..where((m) => m.id.isIn(matchIds)) + ..orderBy([(m) => OrderingTerm.desc(m.createdAt)])) + .get(); + + return Future.wait( + rows.map((row) async { + final game = await db.gameDao.getGameById(gameId: row.gameId); + + Group? group; + if (row.groupId != null) { + group = await db.groupDao.getGroupById(groupId: row.groupId!); + } + + final players = await db.playerMatchDao.getPlayersOfMatch( + matchId: row.id, + ); + final scores = await db.scoreEntryDao.getAllMatchScores( + matchId: row.id, + ); + final teams = await _getMatchTeams(matchId: row.id); + + return Match( + id: row.id, + name: row.name, + game: game, + group: group, + players: players, + teams: teams.isEmpty ? null : teams, + notes: row.notes, + createdAt: row.createdAt, + endedAt: row.endedAt, + scores: scores, + ); + }), + ); + } + /// Retrieves all matches associated with the given [groupId]. /// Queries the database directly, filtering by [groupId]. Future> getMatchesByGroup({required String groupId}) async { diff --git a/lib/presentation/views/main_menu/player_detail_view.dart b/lib/presentation/views/main_menu/player_detail_view.dart new file mode 100644 index 0000000..a946133 --- /dev/null +++ b/lib/presentation/views/main_menu/player_detail_view.dart @@ -0,0 +1,273 @@ +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/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 createState() => _PlayerDetailViewState(); +} + +class _PlayerDetailViewState extends State { + late final AppDatabase db; + + 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 playerGroups = List.filled( + 4, + Group(name: "Skeleton group", members: []), + ); + + /// Full list of matches this player played in + List playerMatches = List.filled( + 4, + Match( + name: 'Skeleton match', + game: Game(name: 'Game name', ruleset: Ruleset.singleWinner), + players: [], + ), + ); + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + _loadData(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Player Profile'), + actions: [ + HapticIconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + showDialog( + context: context, + builder: (context) => CustomAlertDialog( + title: '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), + Text( + widget.player.name + getNameCountText(widget.player), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 5), + Text( + '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(widget.player.createdAt)}', + style: const TextStyle( + fontSize: 12, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + InfoTile( + title: "Matches played in (${totalMatches})", + icon: Icons.people, + horizontalAlignment: CrossAxisAlignment.start, + content: Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: playerMatches.map((match) { + return TextIconTile(text: match.name, iconEnabled: false); + }).toList(), + ), + ), + const SizedBox(height: 15), + InfoTile( + title: "Groups part of (${totalGroups})", + icon: Icons.people, + horizontalAlignment: CrossAxisAlignment.start, + content: Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: playerGroups.map((group) { + return TextIconTile(text: group.name, iconEnabled: false); + }).toList(), + ), + ), + const SizedBox(height: 15), + InfoTile( + title: loc.statistics, + icon: Icons.bar_chart, + content: AppSkeleton( + enabled: isLoading, + child: Column( + children: [ + _buildStatRow( + "Matches played", + totalMatches.toString(), + ), + _buildStatRow("Matches won", matchesWon.toString()), + _buildStatRow( + "Winrate", + '${totalMatches == 0 ? 0 : ((matchesWon / totalMatches) * 100).round()}%', + ), + ], + ), + ), + ), + ], + ), + Positioned( + bottom: MediaQuery.paddingOf(context).bottom, + child: MainMenuButton( + text: "Edit player", + icon: Icons.edit, + onPressed: () async { + //TODO: update player name in popup + widget.callback(); + }, + ), + ), + ], + ), + ), + ); + } + + /// Loads statistics for this player + Future _loadData() async { + isLoading = true; + final fetchedMatches = await db.matchDao.getMatchesByPlayer( + playerId: widget.player.id, + ); + final fetchedGroups = await db.groupDao.getGroupsByPlayer( + playerId: widget.player.id, + ); + + setState(() { + playerMatches = fetchedMatches; + totalMatches = fetchedMatches.length; + matchesWon = fetchedMatches + .where((match) => match.mvp.any((mvp) => mvp.id == widget.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), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index f6c406e..6f22d39 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/data/models/group.dart'; +import 'package:tallee/presentation/views/main_menu/player_detail_view.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; class GroupTile extends StatefulWidget { @@ -92,6 +94,19 @@ class _GroupTileState extends State { text: member.name, suffixText: getNameCountText(member), iconEnabled: false, + onTileTap: () { + Navigator.push( + context, + adaptivePageRoute( + builder: (context) => PlayerDetailView( + player: member, + callback: () { + //TODO: implement callback + }, + ), + ), + ); + }, ), ], ), diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index 6a81dc3..d9583e7 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -3,11 +3,13 @@ import 'dart:core' hide Match; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/player_detail_view.dart'; import 'package:tallee/presentation/widgets/game_label.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -224,6 +226,19 @@ class _MatchTileState extends State { text: player.name, suffixText: getNameCountText(player), iconEnabled: false, + onTileTap: () { + Navigator.push( + context, + adaptivePageRoute( + builder: (context) => PlayerDetailView( + player: player, + callback: () { + //TODO: implement callback + }, + ), + ), + ); + }, ); }).toList(), ), diff --git a/lib/presentation/widgets/tiles/text_icon_tile.dart b/lib/presentation/widgets/tiles/text_icon_tile.dart index 541b6ae..a5ef399 100644 --- a/lib/presentation/widgets/tiles/text_icon_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_tile.dart @@ -12,6 +12,7 @@ class TextIconTile extends StatelessWidget { this.suffixText = '', this.iconEnabled = true, this.onIconTap, + this.onTileTap, }); /// The text to display in the tile. @@ -25,52 +26,58 @@ class TextIconTile extends StatelessWidget { /// The callback to be invoked when the icon is tapped. final VoidCallback? onIconTap; + /// The callback to be invoked when the tile is tapped. + final VoidCallback? onTileTap; + @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: CustomTheme.onBoxColor, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - if (iconEnabled) const SizedBox(width: 3), - Flexible( - child: RichText( - overflow: TextOverflow.ellipsis, - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: text, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + return GestureDetector( + onTap: onTileTap, + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: CustomTheme.onBoxColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + if (iconEnabled) const SizedBox(width: 3), + Flexible( + child: RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), - ), - TextSpan( - text: suffixText, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: CustomTheme.textColor.withAlpha(120), + TextSpan( + text: suffixText, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: CustomTheme.textColor.withAlpha(120), + ), ), - ), - ], + ], + ), ), ), - ), - if (iconEnabled) ...[ - const SizedBox(width: 3), - GestureDetector( - onTap: onIconTap, - child: const Icon(Icons.close, size: 20), - ), + if (iconEnabled) ...[ + const SizedBox(width: 3), + GestureDetector( + onTap: onIconTap, + child: const Icon(Icons.close, size: 20), + ), + ], ], - ], + ), ), ); } diff --git a/test/db_tests/aggregates/group_test.dart b/test/db_tests/aggregates/group_test.dart index 1498523..3ea625f 100644 --- a/test/db_tests/aggregates/group_test.dart +++ b/test/db_tests/aggregates/group_test.dart @@ -194,6 +194,31 @@ void main() { expect(allGroups, isEmpty); }); + test('getGroupsByPlayer() works correctly', () async { + await database.groupDao.addGroupsAsList( + groups: [testGroup1, testGroup2], + ); + + final groups = await database.groupDao.getGroupsByPlayer( + playerId: testPlayer2.id, + ); + + expect(groups, hasLength(2)); + expect(groups.any((group) => group.id == testGroup1.id), isTrue); + expect(groups.any((group) => group.id == testGroup2.id), isTrue); + }); + + test( + 'getGroupsByPlayer() returns empty list for non-existent player', + () async { + final groups = await database.groupDao.getGroupsByPlayer( + playerId: 'non-existent-player-id', + ); + + expect(groups, isEmpty); + }, + ); + test('addGroupsAsList() with duplicate groups only adds once', () async { await database.groupDao.addGroupsAsList( groups: [testGroup1, testGroup1, testGroup1], diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index 37c1cd0..00e6e46 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -260,6 +260,34 @@ void main() { expect(match.group!.id, testGroup1.id); }); + test('getMatchesByPlayer() works correctly', () async { + await database.matchDao.addMatchesAsList( + matches: [testMatch1, testMatch2], + ); + + final matches = await database.matchDao.getMatchesByPlayer( + playerId: testPlayer1.id, + ); + + expect(matches, hasLength(1)); + expect(matches.first.id, testMatch2.id); + expect( + matches.first.players.any((p) => p.id == testPlayer1.id), + isTrue, + ); + }); + + test( + 'getMatchesByPlayer() returns empty list for non-existent player', + () async { + final matches = await database.matchDao.getMatchesByPlayer( + playerId: 'non-existing-player-id', + ); + + expect(matches, isEmpty); + }, + ); + test('getMatchCount() works correctly', () async { var count = await database.matchDao.getMatchCount(); expect(count, 0);