diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index 16e9585..5930901 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -8,6 +8,19 @@ class CustomTheme { static Color onBoxColor = const Color(0xFF181818); static Color boxBorder = const Color(0xFF272727); + static BoxDecoration standardBoxDecoration = BoxDecoration( + color: boxColor, + border: Border.all(color: boxBorder), + borderRadius: BorderRadius.circular(12), + ); + + static BoxDecoration highlightedBoxDecoration = BoxDecoration( + color: boxColor, + border: Border.all(color: primaryColor), + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)], + ); + static AppBarTheme appBarTheme = AppBarTheme( backgroundColor: backgroundColor, foregroundColor: Colors.white, diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 8c809b0..af1f4a6 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -22,3 +22,10 @@ enum ImportResult { /// - [ExportResult.canceled]: The export operation was canceled by the user. /// - [ExportResult.unknownException]: An exception occurred during export. enum ExportResult { success, canceled, unknownException } + +/// Different rulesets available for games +/// - [Ruleset.singleWinner]: The game is won by a single player +/// - [Ruleset.singleLoser]: The game is lost by a single player +/// - [Ruleset.mostPoints]: The player with the most points wins. +/// - [Ruleset.lastPoints]: The player with the fewest points wins. +enum Ruleset { singleWinner, singleLoser, mostPoints, lastPoints } diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index 18792b5..c941499 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -78,7 +78,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { ); if (game.players != null) { - await db.playerDao.addPlayers(players: game.players!); + await db.playerDao.addPlayersAsList(players: game.players!); for (final p in game.players ?? []) { await db.playerGameDao.addPlayerToGame( gameId: game.id, @@ -89,12 +89,18 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { if (game.group != null) { await db.groupDao.addGroup(group: game.group!); - await db.groupGameDao.addGroupToGame(game.id, game.group!.id); + await db.groupGameDao.addGroupToGame( + gameId: game.id, + groupId: game.group!.id, + ); } }); } - Future addGames({required List games}) async { + /// 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. + Future addGamesAsList({required List games}) async { if (games.isEmpty) return; await db.transaction(() async { // Add all games in batch @@ -253,4 +259,62 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { final rowsAffected = await query.go(); return rowsAffected > 0; } + + /// Sets the winner of the game with the given [gameId] to the player with + /// the given [winnerId]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future setWinner({ + required String gameId, + required String winnerId, + }) async { + final query = update(gameTable)..where((g) => g.id.equals(gameId)); + final rowsAffected = await query.write( + GameTableCompanion(winnerId: Value(winnerId)), + ); + return rowsAffected > 0; + } + + /// Retrieves the winner of the game with the given [gameId]. + /// Returns the [Player] who won the game, or `null` if no winner is set. + Future getWinner({required String gameId}) async { + final query = select(gameTable)..where((g) => g.id.equals(gameId)); + final result = await query.getSingleOrNull(); + if (result == null || result.winnerId == null) { + return null; + } + final winner = await db.playerDao.getPlayerById(playerId: result.winnerId!); + return winner; + } + + /// Removes the winner of the game with the given [gameId]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future removeWinner({required String gameId}) async { + final query = update(gameTable)..where((g) => g.id.equals(gameId)); + final rowsAffected = await query.write( + const GameTableCompanion(winnerId: Value(null)), + ); + return rowsAffected > 0; + } + + /// Checks if the game with the given [gameId] has a winner set. + /// Returns `true` if a winner is set, otherwise `false`. + Future hasWinner({required String gameId}) async { + final query = select(gameTable) + ..where((g) => g.id.equals(gameId) & g.winnerId.isNotNull()); + final result = await query.getSingleOrNull(); + return result != null; + } + + /// Changes the title of the game with the given [gameId] to [newName]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future updateGameName({ + required String gameId, + required String newName, + }) async { + final query = update(gameTable)..where((g) => g.id.equals(gameId)); + final rowsAffected = await query.write( + GameTableCompanion(name: Value(newName)), + ); + return rowsAffected > 0; + } } diff --git a/lib/data/dao/group_dao.dart b/lib/data/dao/group_dao.dart index fbb4d6f..643bc88 100644 --- a/lib/data/dao/group_dao.dart +++ b/lib/data/dao/group_dao.dart @@ -84,7 +84,7 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { /// Adds multiple groups to the database. /// Also adds the group's members to the [PlayerGroupTable]. - Future addGroups({required List groups}) async { + Future addGroupsAsList({required List groups}) async { if (groups.isEmpty) return; await db.transaction(() async { // Deduplicate groups by id - keep first occurrence diff --git a/lib/data/dao/group_game_dao.dart b/lib/data/dao/group_game_dao.dart index f3ddcc7..da95607 100644 --- a/lib/data/dao/group_game_dao.dart +++ b/lib/data/dao/group_game_dao.dart @@ -12,7 +12,13 @@ class GroupGameDao extends DatabaseAccessor /// Associates a group with a game by inserting a record into the /// [GroupGameTable]. - Future addGroupToGame(String gameId, String groupId) async { + 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, @@ -76,4 +82,17 @@ class GroupGameDao extends DatabaseAccessor final rowsAffected = await query.go(); return rowsAffected > 0; } + + /// Updates the group associated with a game to [newGroupId] based on + /// [gameId]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future updateGroupOfGame({ + required String gameId, + required String newGroupId, + }) async { + final updatedRows = + await (update(groupGameTable)..where((g) => g.gameId.equals(gameId))) + .write(GroupGameTableCompanion(groupId: Value(newGroupId))); + return updatedRows > 0; + } } diff --git a/lib/data/dao/player_dao.dart b/lib/data/dao/player_dao.dart index 53e251f..8a58504 100644 --- a/lib/data/dao/player_dao.dart +++ b/lib/data/dao/player_dao.dart @@ -50,7 +50,7 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { } /// Adds multiple [players] to the database in a batch operation. - Future addPlayers({required List players}) async { + Future addPlayersAsList({required List players}) async { if (players.isEmpty) return false; await db.batch( diff --git a/lib/data/dao/player_game_dao.dart b/lib/data/dao/player_game_dao.dart index ef15a80..b7f253f 100644 --- a/lib/data/dao/player_game_dao.dart +++ b/lib/data/dao/player_game_dao.dart @@ -79,4 +79,50 @@ class PlayerGameDao extends DatabaseAccessor final rowsAffected = await query.go(); return rowsAffected > 0; } + + /// Updates the players associated with a game based on the provided + /// [newPlayer] list. It adds new players and removes players that are no + /// longer associated with the game. + Future updatePlayersFromGame({ + required String gameId, + required List newPlayer, + }) async { + final currentPlayers = await getPlayersOfGame(gameId: gameId); + // Create sets of player IDs for easy comparison + final currentPlayerIds = currentPlayers?.map((p) => p.id).toSet() ?? {}; + final newPlayerIdsSet = newPlayer.map((p) => p.id).toSet(); + + // Determine players to add and remove + final playersToAdd = newPlayerIdsSet.difference(currentPlayerIds); + final playersToRemove = currentPlayerIds.difference(newPlayerIdsSet); + + db.transaction(() async { + // Remove old players + if (playersToRemove.isNotEmpty) { + await (delete(playerGameTable)..where( + (pg) => + pg.gameId.equals(gameId) & + pg.playerId.isIn(playersToRemove.toList()), + )) + .go(); + } + + // Add new players + if (playersToAdd.isNotEmpty) { + final inserts = playersToAdd + .map( + (id) => + PlayerGameTableCompanion.insert(playerId: id, gameId: gameId), + ) + .toList(); + await Future.wait( + inserts.map( + (c) => into( + playerGameTable, + ).insert(c, mode: InsertMode.insertOrReplace), + ), + ); + } + }); + } } diff --git a/lib/presentation/views/main_menu/create_game/choose_group_view.dart b/lib/presentation/views/main_menu/create_game/choose_group_view.dart new file mode 100644 index 0000000..c98ce6d --- /dev/null +++ b/lib/presentation/views/main_menu/create_game/choose_group_view.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/presentation/widgets/tiles/group_tile.dart'; + +class ChooseGroupView extends StatefulWidget { + final List groups; + final int initialGroupIndex; + + const ChooseGroupView({ + super.key, + required this.groups, + required this.initialGroupIndex, + }); + + @override + State createState() => _ChooseGroupViewState(); +} + +class _ChooseGroupViewState extends State { + late int selectedGroupIndex; + + @override + void initState() { + selectedGroupIndex = widget.initialGroupIndex; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + backgroundColor: CustomTheme.backgroundColor, + scrolledUnderElevation: 0, + title: const Text( + 'Choose Group', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + centerTitle: true, + ), + body: ListView.builder( + padding: const EdgeInsets.only(bottom: 85), + itemCount: widget.groups.length, + itemBuilder: (BuildContext context, int index) { + return GestureDetector( + onTap: () { + setState(() { + selectedGroupIndex = index; + }); + + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + Navigator.of(context).pop(widget.groups[index]); + }); + }, + child: GroupTile( + group: widget.groups[index], + isHighlighted: selectedGroupIndex == index, + ), + ); + }, + ), + ); + } +} diff --git a/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart b/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart new file mode 100644 index 0000000..b54f56e --- /dev/null +++ b/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart @@ -0,0 +1,120 @@ +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/ruleset_list_tile.dart'; + +class ChooseRulesetView extends StatefulWidget { + final List<(Ruleset, String, String)> rulesets; + final int initialRulesetIndex; + const ChooseRulesetView({ + super.key, + required this.rulesets, + required this.initialRulesetIndex, + }); + + @override + State createState() => _ChooseRulesetViewState(); +} + +class _ChooseRulesetViewState extends State { + late int selectedRulesetIndex; + + @override + void initState() { + selectedRulesetIndex = widget.initialRulesetIndex; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + initialIndex: 0, + child: Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + backgroundColor: CustomTheme.backgroundColor, + scrolledUnderElevation: 0, + title: const Text( + 'Choose Ruleset', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + centerTitle: true, + ), + body: Column( + children: [ + Container( + color: CustomTheme.backgroundColor, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: TabBar( + padding: const EdgeInsets.symmetric(horizontal: 5), + // Label Settings + labelStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + labelColor: Colors.white, + unselectedLabelStyle: const TextStyle(fontSize: 14), + unselectedLabelColor: Colors.white70, + // Indicator Settings + indicator: CustomTheme.standardBoxDecoration, + indicatorSize: TabBarIndicatorSize.tab, + indicatorWeight: 1, + indicatorPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 0, + ), + // Divider Settings + dividerHeight: 0, + tabs: const [ + Tab(text: 'Rulesets'), + Tab(text: 'Gametypes'), + ], + ), + ), + const Divider( + indent: 30, + endIndent: 30, + thickness: 3, + radius: BorderRadius.all(Radius.circular(12)), + ), + Expanded( + child: TabBarView( + children: [ + ListView.builder( + padding: const EdgeInsets.only(bottom: 85), + itemCount: widget.rulesets.length, + itemBuilder: (BuildContext context, int index) { + return RulesetListTile( + onPressed: () async { + setState(() { + selectedRulesetIndex = index; + }); + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + Navigator.of( + context, + ).pop(widget.rulesets[index].$1); + }); + }, + title: widget.rulesets[index].$2, + description: widget.rulesets[index].$3, + isHighlighted: selectedRulesetIndex == index, + ); + }, + ), + const Center( + child: Text( + 'No gametypes available', + style: TextStyle(color: Colors.white70), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/views/main_menu/create_game/create_game_view.dart b/lib/presentation/views/main_menu/create_game/create_game_view.dart new file mode 100644 index 0000000..485118b --- /dev/null +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.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_game/choose_group_view.dart'; +import 'package:game_tracker/presentation/views/main_menu/create_game/choose_ruleset_view.dart'; +import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:game_tracker/presentation/widgets/player_selection.dart'; +import 'package:game_tracker/presentation/widgets/text_input/text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/tiles/choose_tile.dart'; +import 'package:provider/provider.dart'; + +class CreateGameView extends StatefulWidget { + const CreateGameView({super.key}); + + @override + State createState() => _CreateGameViewState(); +} + +class _CreateGameViewState extends State { + /// Reference to the app database + late final AppDatabase db; + + /// Futures to load all groups and players from the database + late Future> _allGroupsFuture; + + /// Future to load all players from the database + late Future> _allPlayersFuture; + + /// Controller for the game name input field + final TextEditingController _gameNameController = TextEditingController(); + + /// List of all groups from the database + List groupsList = []; + + /// List of all players from the database + List playerList = []; + + /// The currently selected group + Group? selectedGroup; + + /// The index of the currently selected group in [groupsList] to mark it in + /// the [ChooseGroupView] + int selectedGroupIndex = -1; + + /// The currently selected ruleset + Ruleset? selectedRuleset; + + /// The index of the currently selected ruleset in [rulesets] to mark it in + /// the [ChooseRulesetView] + int selectedRulesetIndex = -1; + + /// The currently selected players + List? selectedPlayers; + + /// List of available rulesets with their display names and descriptions + /// as tuples of (Ruleset, String, String) + List<(Ruleset, String, String)> rulesets = [ + ( + Ruleset.singleWinner, + 'Single Winner', + 'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.', + ), + ( + Ruleset.singleLoser, + 'Single Loser', + 'Exactly one loser is determined; last place receives the penalty or consequence.', + ), + ( + Ruleset.mostPoints, + 'Most Points', + 'Traditional ruleset: the player with the most points wins.', + ), + ( + Ruleset.lastPoints, + 'Least Points', + 'Inverse scoring: the player with the fewest points wins.', + ), + ]; + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + + _allGroupsFuture = db.groupDao.getAllGroups(); + _allPlayersFuture = db.playerDao.getAllPlayers(); + + Future.wait([_allGroupsFuture, _allPlayersFuture]).then((result) async { + groupsList = result[0] as List; + playerList = result[1] as List; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + backgroundColor: CustomTheme.backgroundColor, + scrolledUnderElevation: 0, + title: const Text( + 'Create new game', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + centerTitle: true, + ), + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: TextInputField( + controller: _gameNameController, + hintText: 'Game name', + onChanged: (value) { + setState(() {}); + }, + ), + ), + ChooseTile( + title: 'Ruleset', + trailingText: selectedRuleset == null + ? 'None' + : translateRulesetToString(selectedRuleset!), + onPressed: () async { + selectedRuleset = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseRulesetView( + rulesets: rulesets, + initialRulesetIndex: selectedRulesetIndex, + ), + ), + ); + selectedRulesetIndex = rulesets.indexWhere( + (r) => r.$1 == selectedRuleset, + ); + setState(() {}); + }, + ), + ChooseTile( + title: 'Group', + trailingText: selectedGroup == null + ? 'None' + : selectedGroup!.name, + onPressed: () async { + selectedGroup = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseGroupView( + groups: groupsList, + initialGroupIndex: selectedGroupIndex, + ), + ), + ); + selectedGroupIndex = groupsList.indexWhere( + (g) => g.id == selectedGroup?.id, + ); + setState(() {}); + }, + ), + Expanded( + child: PlayerSelection( + key: ValueKey(selectedGroup?.id ?? 'no_group'), + initialPlayers: selectedGroup == null + ? playerList + : playerList + .where( + (p) => !selectedGroup!.members.any( + (m) => m.id == p.id, + ), + ) + .toList(), + onChanged: (value) { + setState(() { + selectedPlayers = value; + }); + }, + ), + ), + + CustomWidthButton( + text: 'Create game', + sizeRelativeToWidth: 0.95, + buttonType: ButtonType.primary, + onPressed: _enableCreateGameButton() + ? () async { + Game game = Game( + name: _gameNameController.text.trim(), + createdAt: DateTime.now(), + group: selectedGroup!, + players: selectedPlayers, + ); + // TODO: Replace with navigation to GameResultView() + print('Created game: $game'); + Navigator.pop(context); + } + : null, + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } + + /// Translates a [Ruleset] enum value to its corresponding string representation. + String translateRulesetToString(Ruleset ruleset) { + switch (ruleset) { + case Ruleset.singleWinner: + return 'Single Winner'; + case Ruleset.singleLoser: + return 'Single Loser'; + case Ruleset.mostPoints: + return 'Most Points'; + case Ruleset.lastPoints: + return 'Least Points'; + } + } + + /// Determines whether the "Create Game" button should be enabled based on + /// the current state of the input fields. + bool _enableCreateGameButton() { + return _gameNameController.text.isNotEmpty && + (selectedGroup != null || + (selectedPlayers != null && selectedPlayers!.isNotEmpty)) && + selectedRuleset != null; + } +} diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart index 2fe2fc5..cbaee6d 100644 --- a/lib/presentation/views/main_menu/create_group_view.dart +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -6,7 +6,7 @@ import 'package:game_tracker/data/dto/group.dart'; import 'package:game_tracker/data/dto/player.dart'; import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; import 'package:game_tracker/presentation/widgets/player_selection.dart'; -import 'package:game_tracker/presentation/widgets/text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/text_input/text_input_field.dart'; import 'package:provider/provider.dart'; class CreateGroupView extends StatefulWidget { diff --git a/lib/presentation/views/main_menu/game_history_view.dart b/lib/presentation/views/main_menu/game_history_view.dart index 5689c7b..ea5ddd7 100644 --- a/lib/presentation/views/main_menu/game_history_view.dart +++ b/lib/presentation/views/main_menu/game_history_view.dart @@ -5,10 +5,10 @@ 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/widgets/tiles/game_history_tile.dart'; -import 'package:game_tracker/presentation/widgets/top_centered_message.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/game_history_tile.dart'; +import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; import 'package:provider/provider.dart'; class GameHistoryView extends StatefulWidget { @@ -38,9 +38,7 @@ class _GameHistoryViewState extends State { ], ), winner: Player(name: 'Skeleton Player 1'), - players: [ - Player(name: 'Skeleton Player 6') - ], + players: [Player(name: 'Skeleton Player 6')], ), ); @@ -67,49 +65,53 @@ class _GameHistoryViewState extends State { children: [ 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: 'Error', - message: 'No Games Available', - ), - ); - } + 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: 'Error', + message: 'No Games Available', + ), + ); + } - final List games = (isLoading - ? skeletonData - : (snapshot.data ?? []) - ..sort((a, b) => b.createdAt.compareTo(a.createdAt))) - .toList(); + 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(game: games[index]); // Placeholder - }, - ), - ); - }, + 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( + game: games[index], + ); // Placeholder + }, + ), + ); + }, ), Positioned( bottom: MediaQuery.paddingOf(context).bottom, @@ -135,4 +137,4 @@ class _GameHistoryViewState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/views/main_menu/groups_view.dart b/lib/presentation/views/main_menu/groups_view.dart index 29fbac8..4601ef9 100644 --- a/lib/presentation/views/main_menu/groups_view.dart +++ b/lib/presentation/views/main_menu/groups_view.dart @@ -92,7 +92,6 @@ class _GroupsViewState extends State { ); }, ), - Positioned( bottom: MediaQuery.paddingOf(context).bottom, child: CustomWidthButton( diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index ad15363..092a613 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -3,7 +3,7 @@ import 'package:game_tracker/core/custom_theme.dart'; import 'package:game_tracker/data/db/database.dart'; import 'package:game_tracker/data/dto/player.dart'; import 'package:game_tracker/presentation/widgets/app_skeleton.dart'; -import 'package:game_tracker/presentation/widgets/custom_search_bar.dart'; +import 'package:game_tracker/presentation/widgets/text_input/custom_search_bar.dart'; import 'package:game_tracker/presentation/widgets/tiles/text_icon_list_tile.dart'; import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart'; import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; @@ -11,8 +11,13 @@ import 'package:provider/provider.dart'; class PlayerSelection extends StatefulWidget { final Function(List value) onChanged; + final List initialPlayers; - const PlayerSelection({super.key, required this.onChanged}); + const PlayerSelection({ + super.key, + required this.onChanged, + this.initialPlayers = const [], + }); @override State createState() => _PlayerSelectionState(); @@ -46,9 +51,14 @@ class _PlayerSelectionState extends State { suggestedPlayers = skeletonData; _allPlayersFuture.then((loadedPlayers) { setState(() { - loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); - allPlayers = [...loadedPlayers]; - suggestedPlayers = [...loadedPlayers]; + if (widget.initialPlayers.isNotEmpty) { + allPlayers = [...widget.initialPlayers]; + suggestedPlayers = [...widget.initialPlayers]; + } else { + loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); + allPlayers = [...loadedPlayers]; + suggestedPlayers = [...loadedPlayers]; + } }); }); } @@ -58,11 +68,7 @@ class _PlayerSelectionState extends State { return 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), - ), + decoration: CustomTheme.standardBoxDecoration, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/presentation/widgets/custom_search_bar.dart b/lib/presentation/widgets/text_input/custom_search_bar.dart similarity index 100% rename from lib/presentation/widgets/custom_search_bar.dart rename to lib/presentation/widgets/text_input/custom_search_bar.dart diff --git a/lib/presentation/widgets/text_input_field.dart b/lib/presentation/widgets/text_input/text_input_field.dart similarity index 100% rename from lib/presentation/widgets/text_input_field.dart rename to lib/presentation/widgets/text_input/text_input_field.dart diff --git a/lib/presentation/widgets/tiles/choose_tile.dart b/lib/presentation/widgets/tiles/choose_tile.dart new file mode 100644 index 0000000..10a695d --- /dev/null +++ b/lib/presentation/widgets/tiles/choose_tile.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class ChooseTile extends StatefulWidget { + final String title; + final VoidCallback? onPressed; + final String? trailingText; + const ChooseTile({ + super.key, + required this.title, + this.trailingText, + this.onPressed, + }); + + @override + State createState() => _ChooseTileState(); +} + +class _ChooseTileState extends State { + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onPressed, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15), + decoration: CustomTheme.standardBoxDecoration, + child: Row( + children: [ + Text( + widget.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const Spacer(), + if (widget.trailingText != null) Text(widget.trailingText!), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index fa91477..248c1c6 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -4,20 +4,20 @@ import 'package:game_tracker/data/dto/group.dart'; import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart'; class GroupTile extends StatelessWidget { - const GroupTile({super.key, required this.group}); + const GroupTile({super.key, required this.group, this.isHighlighted = false}); final Group group; + final bool isHighlighted; @override Widget build(BuildContext context) { - return Container( + return AnimatedContainer( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - borderRadius: BorderRadius.circular(12), - ), + decoration: isHighlighted + ? CustomTheme.highlightedBoxDecoration + : CustomTheme.standardBoxDecoration, + duration: const Duration(milliseconds: 150), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/presentation/widgets/tiles/info_tile.dart b/lib/presentation/widgets/tiles/info_tile.dart index 168262e..ff73e59 100644 --- a/lib/presentation/widgets/tiles/info_tile.dart +++ b/lib/presentation/widgets/tiles/info_tile.dart @@ -29,11 +29,7 @@ class _InfoTileState extends State { padding: widget.padding ?? const EdgeInsets.all(12), height: widget.height, width: widget.width ?? 380, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - ), + decoration: CustomTheme.standardBoxDecoration, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/presentation/widgets/tiles/quick_info_tile.dart b/lib/presentation/widgets/tiles/quick_info_tile.dart index 423b8d3..d360aba 100644 --- a/lib/presentation/widgets/tiles/quick_info_tile.dart +++ b/lib/presentation/widgets/tiles/quick_info_tile.dart @@ -29,11 +29,7 @@ class _QuickInfoTileState extends State { padding: widget.padding ?? const EdgeInsets.all(12), height: widget.height ?? 110, width: widget.width ?? 180, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - ), + decoration: CustomTheme.standardBoxDecoration, child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ diff --git a/lib/presentation/widgets/tiles/ruleset_list_tile.dart b/lib/presentation/widgets/tiles/ruleset_list_tile.dart new file mode 100644 index 0000000..13eaf82 --- /dev/null +++ b/lib/presentation/widgets/tiles/ruleset_list_tile.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class RulesetListTile extends StatelessWidget { + final String title; + final String description; + final VoidCallback? onPressed; + final bool isHighlighted; + + const RulesetListTile({ + super.key, + required this.title, + required this.description, + this.onPressed, + this.isHighlighted = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: AnimatedContainer( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: isHighlighted + ? CustomTheme.highlightedBoxDecoration + : CustomTheme.standardBoxDecoration, + duration: const Duration(milliseconds: 200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + title, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + ], + ), + const SizedBox(height: 5), + Text(description, style: const TextStyle(fontSize: 14)), + const SizedBox(height: 2.5), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/settings_list_tile.dart b/lib/presentation/widgets/tiles/settings_list_tile.dart index d5c421f..6b43557 100644 --- a/lib/presentation/widgets/tiles/settings_list_tile.dart +++ b/lib/presentation/widgets/tiles/settings_list_tile.dart @@ -26,11 +26,7 @@ class SettingsListTile extends StatelessWidget { 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), - ), + decoration: CustomTheme.standardBoxDecoration, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/presentation/widgets/tiles/text_icon_list_tile.dart b/lib/presentation/widgets/tiles/text_icon_list_tile.dart index 5e272c9..b23ef75 100644 --- a/lib/presentation/widgets/tiles/text_icon_list_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_list_tile.dart @@ -18,11 +18,7 @@ class TextIconListTile extends StatelessWidget { return Container( margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 15), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - borderRadius: BorderRadius.circular(12), - ), + decoration: CustomTheme.standardBoxDecoration, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, diff --git a/lib/services/data_transfer_service.dart b/lib/services/data_transfer_service.dart index eaa9633..f040b0a 100644 --- a/lib/services/data_transfer_service.dart +++ b/lib/services/data_transfer_service.dart @@ -110,9 +110,9 @@ class DataTransferService { .toList() ?? []; - await db.playerDao.addPlayers(players: importedPlayers); - await db.groupDao.addGroups(groups: importedGroups); - await db.gameDao.addGames(games: importedGames); + await db.playerDao.addPlayersAsList(players: importedPlayers); + await db.groupDao.addGroupsAsList(groups: importedGroups); + await db.gameDao.addGamesAsList(games: importedGames); } else { return ImportResult.invalidSchema; } diff --git a/test/db_tests/game_test.dart b/test/db_tests/game_test.dart index 4cf6982..4a5cc32 100644 --- a/test/db_tests/game_test.dart +++ b/test/db_tests/game_test.dart @@ -112,7 +112,7 @@ void main() { }); test('Adding and fetching multiple games works correctly', () async { - await database.gameDao.addGames( + await database.gameDao.addGamesAsList( games: [testGame1, testGame2, testGameOnlyGroup, testGameOnlyPlayers], ); @@ -234,5 +234,97 @@ void main() { gameCount = await database.gameDao.getGameCount(); expect(gameCount, 0); }); + + test('Checking if game has winner works correclty', () async { + await database.gameDao.addGame(game: testGame1); + await database.gameDao.addGame(game: testGameOnlyGroup); + + var hasWinner = await database.gameDao.hasWinner(gameId: testGame1.id); + expect(hasWinner, true); + + hasWinner = await database.gameDao.hasWinner( + gameId: testGameOnlyGroup.id, + ); + expect(hasWinner, false); + }); + + test('Fetching the winner of a game works correctly', () async { + await database.gameDao.addGame(game: testGame1); + + final winner = await database.gameDao.getWinner(gameId: testGame1.id); + if (winner == null) { + fail('Winner is null'); + } else { + expect(winner.id, testGame1.winner!.id); + expect(winner.name, testGame1.winner!.name); + expect(winner.createdAt, testGame1.winner!.createdAt); + } + }); + + test('Updating the winner of a game works correctly', () async { + await database.gameDao.addGame(game: testGame1); + + final winner = await database.gameDao.getWinner(gameId: testGame1.id); + if (winner == null) { + fail('Winner is null'); + } else { + expect(winner.id, testGame1.winner!.id); + expect(winner.name, testGame1.winner!.name); + expect(winner.createdAt, testGame1.winner!.createdAt); + expect(winner.id, testPlayer4.id); + expect(winner.id != testPlayer5.id, true); + } + + await database.gameDao.setWinner( + gameId: testGame1.id, + winnerId: testPlayer5.id, + ); + + final newWinner = await database.gameDao.getWinner(gameId: testGame1.id); + + if (newWinner == null) { + fail('New winner is null'); + } else { + expect(newWinner.id, testPlayer5.id); + expect(newWinner.name, testPlayer5.name); + expect(newWinner.createdAt, testPlayer5.createdAt); + } + }); + + test('Removing a winner works correctly', () async { + await database.gameDao.addGame(game: testGame2); + + var hasWinner = await database.gameDao.hasWinner(gameId: testGame2.id); + expect(hasWinner, true); + + await database.gameDao.removeWinner(gameId: testGame2.id); + + hasWinner = await database.gameDao.hasWinner(gameId: testGame2.id); + expect(hasWinner, false); + + final removedWinner = await database.gameDao.getWinner( + gameId: testGame2.id, + ); + + expect(removedWinner, null); + }); + + test('Renaming a game works correctly', () async { + await database.gameDao.addGame(game: testGame1); + + var fetchedGame = await database.gameDao.getGameById( + gameId: testGame1.id, + ); + expect(fetchedGame.name, testGame1.name); + + const newName = 'Updated Game Name'; + await database.gameDao.updateGameName( + gameId: testGame1.id, + newName: newName, + ); + + fetchedGame = await database.gameDao.getGameById(gameId: testGame1.id); + expect(fetchedGame.name, newName); + }); }); } diff --git a/test/db_tests/group_game_test.dart b/test/db_tests/group_game_test.dart index 1733243..1e9b8fc 100644 --- a/test/db_tests/group_game_test.dart +++ b/test/db_tests/group_game_test.dart @@ -14,7 +14,8 @@ void main() { late Player testPlayer3; late Player testPlayer4; late Player testPlayer5; - late Group testgroup; + late Group testGroup1; + late Group testGroup2; late Game testgameWithGroup; late Game testgameWithPlayers; final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); @@ -35,15 +36,19 @@ void main() { testPlayer3 = Player(name: 'Charlie'); testPlayer4 = Player(name: 'Diana'); testPlayer5 = Player(name: 'Eve'); - testgroup = Group( + testGroup1 = Group( name: 'Test Group', members: [testPlayer1, testPlayer2, testPlayer3], ); + testGroup2 = Group( + name: 'Test Group', + members: [testPlayer3, testPlayer2], + ); testgameWithPlayers = Game( name: 'Test Game with Players', players: [testPlayer4, testPlayer5], ); - testgameWithGroup = Game(name: 'Test Game with Group', group: testgroup); + testgameWithGroup = Game(name: 'Test Game with Group', group: testGroup1); }); }); tearDown(() async { @@ -52,7 +57,7 @@ void main() { group('Group-Game Tests', () { test('Game has group works correctly', () async { await database.gameDao.addGame(game: testgameWithPlayers); - await database.groupDao.addGroup(group: testgroup); + await database.groupDao.addGroup(group: testGroup1); var gameHasGroup = await database.groupGameDao.gameHasGroup( gameId: testgameWithPlayers.id, @@ -61,8 +66,8 @@ void main() { expect(gameHasGroup, false); await database.groupGameDao.addGroupToGame( - testgameWithPlayers.id, - testgroup.id, + gameId: testgameWithPlayers.id, + groupId: testGroup1.id, ); gameHasGroup = await database.groupGameDao.gameHasGroup( @@ -74,15 +79,15 @@ void main() { test('Adding a group to a game works correctly', () async { await database.gameDao.addGame(game: testgameWithPlayers); - await database.groupDao.addGroup(group: testgroup); + await database.groupDao.addGroup(group: testGroup1); await database.groupGameDao.addGroupToGame( - testgameWithPlayers.id, - testgroup.id, + gameId: testgameWithPlayers.id, + groupId: testGroup1.id, ); var groupAdded = await database.groupGameDao.isGroupInGame( gameId: testgameWithPlayers.id, - groupId: testgroup.id, + groupId: testGroup1.id, ); expect(groupAdded, true); @@ -120,14 +125,55 @@ void main() { fail('Group should not be null'); } - expect(group.id, testgroup.id); - expect(group.name, testgroup.name); - expect(group.createdAt, testgroup.createdAt); - expect(group.members.length, testgroup.members.length); + expect(group.id, testGroup1.id); + expect(group.name, testGroup1.name); + expect(group.createdAt, testGroup1.createdAt); + expect(group.members.length, testGroup1.members.length); for (int i = 0; i < group.members.length; i++) { - expect(group.members[i].id, testgroup.members[i].id); - expect(group.members[i].name, testgroup.members[i].name); - expect(group.members[i].createdAt, testgroup.members[i].createdAt); + expect(group.members[i].id, testGroup1.members[i].id); + expect(group.members[i].name, testGroup1.members[i].name); + expect(group.members[i].createdAt, testGroup1.members[i].createdAt); + } + }); + + test('Updating the group of a game works correctly', () async { + await database.gameDao.addGame(game: testgameWithGroup); + + var group = await database.groupGameDao.getGroupOfGame( + gameId: testgameWithGroup.id, + ); + + if (group == null) { + fail('Initial group should not be null'); + } else { + expect(group.id, testGroup1.id); + expect(group.name, testGroup1.name); + expect(group.createdAt, testGroup1.createdAt); + expect(group.members.length, testGroup1.members.length); + } + + await database.groupDao.addGroup(group: testGroup2); + await database.groupGameDao.updateGroupOfGame( + gameId: testgameWithGroup.id, + newGroupId: testGroup2.id, + ); + + group = await database.groupGameDao.getGroupOfGame( + gameId: testgameWithGroup.id, + ); + + if (group == null) { + fail('Updated group should not be null'); + } else { + expect(group.id, testGroup2.id); + expect(group.name, testGroup2.name); + expect(group.createdAt, testGroup2.createdAt); + expect(group.members.length, testGroup2.members.length); + for (int i = 0; i < group.members.length; i++) { + expect(group.members[i].id, testGroup2.members[i].id); + expect(group.members[i].name, testGroup2.members[i].name); + expect(group.members[i].createdAt, testGroup2.members[i].createdAt); + } } }); }); diff --git a/test/db_tests/group_test.dart b/test/db_tests/group_test.dart index a1832c1..5104c65 100644 --- a/test/db_tests/group_test.dart +++ b/test/db_tests/group_test.dart @@ -81,7 +81,7 @@ void main() { }); test('Adding and fetching multiple groups works correctly', () async { - await database.groupDao.addGroups( + await database.groupDao.addGroupsAsList( groups: [testGroup1, testGroup2, testGroup3, testGroup4], ); diff --git a/test/db_tests/player_game_test.dart b/test/db_tests/player_game_test.dart index e8fd707..4e2616e 100644 --- a/test/db_tests/player_game_test.dart +++ b/test/db_tests/player_game_test.dart @@ -136,5 +136,48 @@ void main() { expect(players[i].createdAt, testGameOnlyPlayers.players![i].createdAt); } }); + + test('Updating the games players works coreclty', () async { + await database.gameDao.addGame(game: testGameOnlyPlayers); + + final newPlayers = [testPlayer1, testPlayer2, testPlayer4]; + await database.playerDao.addPlayersAsList(players: newPlayers); + + // First, remove all existing players + final existingPlayers = await database.playerGameDao.getPlayersOfGame( + gameId: testGameOnlyPlayers.id, + ); + + if (existingPlayers == null || existingPlayers.isEmpty) { + fail('Existing players should not be null or empty'); + } + + await database.playerGameDao.updatePlayersFromGame( + gameId: testGameOnlyPlayers.id, + newPlayer: newPlayers, + ); + + final updatedPlayers = await database.playerGameDao.getPlayersOfGame( + gameId: testGameOnlyPlayers.id, + ); + + if (updatedPlayers == null) { + fail('Updated players should not be null'); + } + + expect(updatedPlayers.length, newPlayers.length); + + /// Create a map of new players for easy lookup + final testPlayers = {for (var p in newPlayers) p.id: p}; + + /// Verify each updated player matches the new players + for (final player in updatedPlayers) { + final testPlayer = testPlayers[player.id]!; + + expect(player.id, testPlayer.id); + expect(player.name, testPlayer.name); + expect(player.createdAt, testPlayer.createdAt); + } + }); }); } diff --git a/test/db_tests/player_test.dart b/test/db_tests/player_test.dart index 9a1ba16..5bd10ad 100644 --- a/test/db_tests/player_test.dart +++ b/test/db_tests/player_test.dart @@ -56,7 +56,7 @@ void main() { }); test('Adding and fetching multiple players works correctly', () async { - await database.playerDao.addPlayers( + await database.playerDao.addPlayersAsList( players: [testPlayer1, testPlayer2, testPlayer3, testPlayer4], );