diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index daee4b7..c941499 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -65,7 +65,6 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Adds a new [Game] to the database. /// Also adds associated players and group if they exist. - /// If a game, player, or group already exists, it will be replaced. Future addGame({required Game game}) async { await db.transaction(() async { await into(gameTable).insert( @@ -101,7 +100,6 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Adds multiple [Game]s to the database in a batch operation. /// Also adds associated players and groups if they exist. /// If the [games] list is empty, the method returns immediately. - /// If a game, player, or group already exists, it will be replaced. Future addGamesAsList({required List games}) async { if (games.isEmpty) return; await db.transaction(() async { diff --git a/lib/data/dao/group_game_dao.dart b/lib/data/dao/group_game_dao.dart index 3f7a146..da95607 100644 --- a/lib/data/dao/group_game_dao.dart +++ b/lib/data/dao/group_game_dao.dart @@ -11,12 +11,14 @@ class GroupGameDao extends DatabaseAccessor GroupGameDao(super.db); /// Associates a group with a game by inserting a record into the - /// [GroupGameTable]. If there is already group associated to the game, - /// it will be replaced. + /// [GroupGameTable]. Future addGroupToGame({ required String gameId, required String groupId, }) async { + if (await gameHasGroup(gameId: gameId)) { + throw Exception('Game already has a group'); + } await into(groupGameTable).insert( GroupGameTableCompanion.insert(groupId: groupId, gameId: gameId), mode: InsertMode.insertOrReplace, diff --git a/lib/presentation/views/main_menu/game_history_view.dart b/lib/presentation/views/main_menu/game_history_view.dart index 7fbe025..31d1b56 100644 --- a/lib/presentation/views/main_menu/game_history_view.dart +++ b/lib/presentation/views/main_menu/game_history_view.dart @@ -1,9 +1,17 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:game_tracker/core/custom_theme.dart'; -import 'package:game_tracker/presentation/views/main_menu/create_game/create_game_view.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/views/main_menu/create_group_view.dart'; +import 'package:game_tracker/presentation/views/main_menu/game_result_view.dart'; +import 'package:game_tracker/presentation/widgets/app_skeleton.dart'; import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; -import 'package:game_tracker/presentation/widgets/tiles/double_row_info_tile.dart'; +import 'package:game_tracker/presentation/widgets/tiles/game_history_tile.dart'; import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; +import 'package:provider/provider.dart'; class GameHistoryView extends StatefulWidget { const GameHistoryView({super.key}); @@ -13,119 +21,36 @@ class GameHistoryView extends StatefulWidget { } class _GameHistoryViewState extends State { - final allGameData = [ - { - 'game': 'Schach', - 'title': 'Abendpartie', - 'players': 2, - 'group': 'Familie', - 'date': '01.06.2024', - }, - { - 'game': 'Monopoly', - 'title': 'Wochenendspaß mit Gras du Saas', - 'players': 4, - 'group': 'Freunde', - 'date': '28.05.2024', - }, - { - 'game': 'Catan', - 'title': 'Strategieabend', - 'players': 3, - 'group': 'Brettspieler', - 'date': '25.05.2024', - }, - { - 'game': 'Uno', - 'title': 'Schnelle Runde', - 'players': 5, - 'group': 'Kollegen', - 'date': '22.05.2024', - }, - { - 'game': 'Poker', - 'title': 'Freitagspoker', - 'players': 6, - 'group': 'Pokerclub', - 'date': '20.05.2024', - }, - { - 'game': 'Scrabble', - 'title': 'Wortschlacht', - 'players': 4, - 'group': 'Familie', - 'date': '18.05.2024', - }, - { - 'game': 'Risiko', - 'title': 'Weltherrschaft', - 'players': 5, - 'group': 'Strategiegruppe', - 'date': '15.05.2024', - }, - { - 'game': 'Zug um Zug', - 'title': 'Zug-Abenteuer', - 'players': 4, - 'group': 'Reisende', - 'date': '12.05.2024', - }, - { - 'game': 'Carcassonne', - 'title': 'Plättchenlegen', - 'players': 3, - 'group': 'Brettspieler', - 'date': '10.05.2024', - }, - { - 'game': 'Pandemie', - 'title': 'Welt retten', - 'players': 4, - 'group': 'Koop-Team', - 'date': '08.05.2024', - }, - { - 'game': 'Cluedo', - 'title': 'Krimiabend', - 'players': 6, - 'group': 'Detektive', - 'date': '05.05.2024', - }, - { - 'game': 'Dixit', - 'title': 'Fantasiespiel', - 'players': 5, - 'group': 'Künstler', - 'date': '02.05.2024', - }, - { - 'game': 'Azul', - 'title': 'Plättchenmeister', - 'players': 4, - 'group': 'Familie', - 'date': '30.04.2024', - }, - { - 'game': 'Splendor', - 'title': 'Edelsteinhändler', - 'players': 3, - 'group': 'Freunde', - 'date': '28.04.2024', - }, - { - 'game': '7 Wonders', - 'title': 'Antike Reiche', - 'players': 7, - 'group': 'Geschichtsfreunde', - 'date': '25.04.2024', - }, - ]; - late List> suggestedGameData; + late Future> _gameListFuture; + late final AppDatabase db; + + late final List skeletonData = List.filled( + 4, + Game( + name: 'Skeleton Game', + group: Group( + name: 'Skeleton Group', + members: [ + Player(name: 'Player 1'), + Player(name: 'Player 2'), + Player(name: 'Player 3'), + Player(name: 'Long Name Player 4'), + Player(name: 'Player 5'), + ], + ), + winner: Player(name: 'Skeleton Player 1'), + players: [Player(name: 'Skeleton Player 6')], + ), + ); @override void initState() { super.initState(); - suggestedGameData = List.from(allGameData); + db = Provider.of(context, listen: false); + _gameListFuture = Future.delayed( + const Duration(milliseconds: 250), + () => db.gameDao.getAllGames(), + ); } @override @@ -133,65 +58,89 @@ class _GameHistoryViewState extends State { return Scaffold( backgroundColor: CustomTheme.backgroundColor, body: Stack( + alignment: Alignment.center, children: [ - Column( - children: [ - Container(margin: const EdgeInsets.only(bottom: 75)), - Expanded( - child: gameHistoryListView(allGameData, suggestedGameData), - ), - ], - ), - Container( - margin: const EdgeInsets.only( - top: 10, - bottom: 10, - left: 10, - right: 10, - ), - child: SearchBar( - leading: const Icon(Icons.search), - onChanged: (value) { - if (value.isEmpty) { - setState(() { - suggestedGameData.clear(); - suggestedGameData.addAll(allGameData); - }); - return; - } - final suggestions = allGameData.where((currentGame) { - return currentGame['game'].toString().toLowerCase().contains( - value.toLowerCase(), - ) || - currentGame['title'].toString().toLowerCase().contains( - value.toLowerCase(), - ) || - currentGame['group'].toString().toLowerCase().contains( - value.toLowerCase(), - ); - }); - setState(() { - suggestedGameData.clear(); - suggestedGameData.addAll(suggestions); - }); - }, - ), - ), - Positioned( - bottom: MediaQuery.paddingOf(context).bottom, - width: MediaQuery.of(context).size.width, - child: Center( - child: CustomWidthButton( - text: 'Create Game', - sizeRelativeToWidth: 0.90, - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const CreateGameView(), + FutureBuilder>( + future: _gameListFuture, + builder: + (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasError) { + return const Center( + child: TopCenteredMessage( + icon: Icons.report, + title: 'Error', + message: 'Game data could not be loaded', + ), + ); + } + if (snapshot.connectionState == ConnectionState.done && + (!snapshot.hasData || snapshot.data!.isEmpty)) { + return const Center( + child: TopCenteredMessage( + icon: Icons.report, + title: 'Info', + message: 'No games created yet', + ), + ); + } + final bool isLoading = + snapshot.connectionState == ConnectionState.waiting; + final List games = + (isLoading ? skeletonData : (snapshot.data ?? []) + ..sort( + (a, b) => b.createdAt.compareTo(a.createdAt), + )) + .toList(); + return AppSkeleton( + enabled: isLoading, + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 85), + itemCount: games.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == games.length) { + return SizedBox( + height: MediaQuery.paddingOf(context).bottom - 80, + ); + } + return GameHistoryTile( + onTap: () async { + await Navigator.push( + context, + CupertinoPageRoute( + fullscreenDialog: true, + builder: (context) => + GameResultView(game: games[index]), + ), + ); + setState(() { + _gameListFuture = db.gameDao.getAllGames(); + }); + }, + game: games[index], + ); + }, ), ); }, - ), + ), + Positioned( + bottom: MediaQuery.paddingOf(context).bottom, + child: CustomWidthButton( + text: 'Create Game', + sizeRelativeToWidth: 0.90, + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const CreateGroupView(); + }, + ), + ); + setState(() { + _gameListFuture = db.gameDao.getAllGames(); + }); + }, ), ), ], @@ -199,32 +148,3 @@ class _GameHistoryViewState extends State { ); } } - -Widget gameHistoryListView(allGameData, suggestedGameData) { - if (suggestedGameData.isEmpty && allGameData.isEmpty) { - return const TopCenteredMessage( - icon: Icons.info, - title: 'Info', - message: 'Keine Spiele erstellt', - ); - } else if (suggestedGameData.isEmpty) { - return const TopCenteredMessage( - icon: Icons.search, - title: 'Info', - message: 'Kein Spiel mit den Suchparametern gefunden.', - ); - } - return ListView.builder( - itemCount: suggestedGameData.length, - itemBuilder: (context, index) { - final currentGame = suggestedGameData[index]; - return doubleRowInfoTile( - currentGame['game'] + ': ', - currentGame['title'], - "${currentGame['players']} Spieler", - currentGame['group'], - currentGame['date'], - ); - }, - ); -} diff --git a/lib/presentation/views/main_menu/game_result_view.dart b/lib/presentation/views/main_menu/game_result_view.dart new file mode 100644 index 0000000..f13553b --- /dev/null +++ b/lib/presentation/views/main_menu/game_result_view.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.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/custom_radio_list_tile.dart'; +import 'package:provider/provider.dart'; + +class GameResultView extends StatefulWidget { + final Game game; + + const GameResultView({super.key, required this.game}); + + @override + State createState() => _GameResultViewState(); +} + +class _GameResultViewState extends State { + late final List allPlayers; + late final AppDatabase db; + Player? _selectedPlayer; + + @override + void initState() { + db = Provider.of(context, listen: false); + allPlayers = getAllPlayers(widget.game); + if (widget.game.winner != null) { + _selectedPlayer = allPlayers.firstWhere( + (p) => p.id == widget.game.winner!.id, + ); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + backgroundColor: CustomTheme.backgroundColor, + scrolledUnderElevation: 0, + title: Text( + widget.game.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 10, + ), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Select Winner:', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Expanded( + child: RadioGroup( + groupValue: _selectedPlayer, + onChanged: (Player? value) async { + setState(() { + _selectedPlayer = value; + }); + await _handleWinnerSaving(); + }, + child: ListView.builder( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return CustomRadioListTile( + text: allPlayers[index].name, + value: allPlayers[index], + onContainerTap: (value) async { + setState(() { + // Check if the already selected player is the same as the newly tapped player. + if (_selectedPlayer == value) { + // If yes deselected the player by setting it to null. + _selectedPlayer = null; + } else { + // If no assign the newly tapped player to the selected player. + (_selectedPlayer = value); + } + }); + await _handleWinnerSaving(); + }, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Future _handleWinnerSaving() async { + if (_selectedPlayer == null) { + await db.gameDao.removeWinner(gameId: widget.game.id); + } else { + await db.gameDao.setWinner( + gameId: widget.game.id, + winnerId: _selectedPlayer!.id, + ); + } + } + + List getAllPlayers(Game game) { + if (game.group == null && game.players != null) { + return [...game.players!]; + } else if (game.group != null && game.players != null) { + return [...game.players!, ...game.group!.members]; + } + return [...game.group!.members]; + } +} diff --git a/lib/presentation/views/main_menu/groups_view.dart b/lib/presentation/views/main_menu/groups_view.dart index 4601ef9..ce47f90 100644 --- a/lib/presentation/views/main_menu/groups_view.dart +++ b/lib/presentation/views/main_menu/groups_view.dart @@ -56,7 +56,7 @@ class _GroupsViewState extends State { child: TopCenteredMessage( icon: Icons.report, title: 'Error', - message: 'Group data couldn\'t\nbe loaded.', + message: 'Group data couldn\'t\nbe loaded', ), ); } @@ -66,7 +66,7 @@ class _GroupsViewState extends State { child: TopCenteredMessage( icon: Icons.info, title: 'Info', - message: 'No groups created yet.', + message: 'No groups created yet', ), ); } diff --git a/lib/presentation/widgets/tiles/custom_radio_list_tile.dart b/lib/presentation/widgets/tiles/custom_radio_list_tile.dart new file mode 100644 index 0000000..11e8b40 --- /dev/null +++ b/lib/presentation/widgets/tiles/custom_radio_list_tile.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class CustomRadioListTile extends StatelessWidget { + final String text; + final T value; + final ValueChanged onContainerTap; + + const CustomRadioListTile({ + super.key, + required this.text, + required this.value, + required this.onContainerTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onContainerTap(value), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Radio( + value: value, + activeColor: CustomTheme.primaryColor, + toggleable: true, + ), + Expanded( + child: Text( + text, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/double_row_info_tile.dart b/lib/presentation/widgets/tiles/double_row_info_tile.dart deleted file mode 100644 index 57404ff..0000000 --- a/lib/presentation/widgets/tiles/double_row_info_tile.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:game_tracker/core/custom_theme.dart'; - -Widget doubleRowInfoTile( - String titleOneUpperLeft, - String titleTwoUpperLeft, - String titleUpperRight, - String titleLowerLeft, - String titleLowerRight, -) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: CustomTheme.secondaryColor, - ), - child: Column( - children: [ - Row( - children: [ - Expanded( - flex: 10, - child: Text( - '$titleOneUpperLeft $titleTwoUpperLeft', - style: const TextStyle(fontSize: 20), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - const Spacer(), - Expanded( - flex: 3, - child: Text( - titleUpperRight, - style: const TextStyle(fontSize: 20), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.end, - ), - ), - ], - ), - Row( - children: [ - Expanded( - flex: 10, - child: Text( - titleLowerLeft, - style: const TextStyle(fontSize: 20), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - const Spacer(), - Expanded( - flex: 4, - child: Text( - titleLowerRight, - style: const TextStyle(fontSize: 20), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.end, - ), - ), - ], - ), - ], - ), - ); -} diff --git a/lib/presentation/widgets/tiles/game_history_tile.dart b/lib/presentation/widgets/tiles/game_history_tile.dart new file mode 100644 index 0000000..83da859 --- /dev/null +++ b/lib/presentation/widgets/tiles/game_history_tile.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart'; +import 'package:intl/intl.dart'; + +class GameHistoryTile extends StatefulWidget { + final Game game; + final VoidCallback onTap; + + const GameHistoryTile({super.key, required this.game, required this.onTap}); + + @override + State createState() => _GameHistoryTileState(); +} + +class _GameHistoryTileState extends State { + @override + Widget build(BuildContext context) { + final group = widget.game.group; + final winner = widget.game.winner; + final allPlayers = _getAllPlayers(); + + return GestureDetector( + onTap: widget.onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.game.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _formatDate(widget.game.createdAt), + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + + const SizedBox(height: 8), + + if (group != null) ...[ + Row( + children: [ + const Icon(Icons.group, size: 16, color: Colors.grey), + const SizedBox(width: 6), + Expanded( + child: Text( + group.name, + style: const TextStyle(fontSize: 14, color: Colors.grey), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + + if (winner != null) ...[ + Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 12, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.green.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.emoji_events, + size: 20, + color: Colors.amber, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Winner: ${winner.name}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + + if (allPlayers.isNotEmpty) ...[ + const Text( + 'Players', + style: TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: allPlayers.map((player) { + return TextIconTile(text: player.name, iconEnabled: false); + }).toList(), + ), + ], + ], + ), + ), + ); + } + + String _formatDate(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inDays == 0) { + return 'Today at ${DateFormat('HH:mm').format(dateTime)}'; + } else if (difference.inDays == 1) { + return 'Yesterday at ${DateFormat('HH:mm').format(dateTime)}'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else { + return DateFormat('MMM d, yyyy').format(dateTime); + } + } + + List _getAllPlayers() { + final allPlayers = []; + final playerIds = {}; + + // Add players from game.players + if (widget.game.players != null) { + for (var player in widget.game.players!) { + if (!playerIds.contains(player.id)) { + allPlayers.add(player); + playerIds.add(player.id); + } + } + } + + // Add players from game.group.players + if (widget.game.group?.members != null) { + for (var player in widget.game.group!.members) { + if (!playerIds.contains(player.id)) { + allPlayers.add(player); + playerIds.add(player.id); + } + } + } + + return allPlayers; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fa4c213..07e4df2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: json_schema: ^5.2.2 file_saver: ^0.3.1 clock: ^1.1.2 + intl: ^0.18.0 dev_dependencies: flutter_test: diff --git a/test/db_tests/player_game_test.dart b/test/db_tests/player_game_test.dart index 6fd87c2..4e2616e 100644 --- a/test/db_tests/player_game_test.dart +++ b/test/db_tests/player_game_test.dart @@ -148,7 +148,6 @@ void main() { gameId: testGameOnlyPlayers.id, ); - print('existingPlayers: $existingPlayers'); if (existingPlayers == null || existingPlayers.isEmpty) { fail('Existing players should not be null or empty'); }