From 487a921defda39d05842a550db977e4ccb9958ba Mon Sep 17 00:00:00 2001 From: Mathis Kirchner Date: Sun, 8 Mar 2026 17:01:41 +0100 Subject: [PATCH] add error message for game deletion and implement search functionality --- lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 4 + lib/l10n/generated/app_localizations.dart | 6 ++ lib/l10n/generated/app_localizations_de.dart | 4 + lib/l10n/generated/app_localizations_en.dart | 4 + .../create_match/choose_game_view.dart | 86 ++++++++++++---- .../create_match/create_match_view.dart | 70 ++++++------- .../game_view/create_game_view.dart | 99 ++++++++++++++++++- .../main_menu/match_view/match_view.dart | 7 +- 9 files changed, 225 insertions(+), 56 deletions(-) diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index b92cc4c..fb58294 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -34,6 +34,7 @@ "edit_match": "Gruppe bearbeiten", "enter_results": "Ergebnisse eintragen", "error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", + "error_deleting_game": "Fehler beim Löschen der Spielvorlage, bitte erneut versuchen", "error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen", "error_editing_group": "Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen", "error_reading_file": "Fehler beim Lesen der Datei", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c242a34..6903ad0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -104,6 +104,9 @@ "@error_creating_group": { "description": "Error message when group creation fails" }, + "@error_deleting_game": { + "description": "Error message when game deletion fails" + }, "@error_deleting_group": { "description": "Error message when group deletion fails" }, @@ -376,6 +379,7 @@ "edit_match": "Edit Match", "enter_results": "Enter Results", "error_creating_group": "Error while creating group, please try again", + "error_deleting_game": "Error while deleting game, please try again", "error_deleting_group": "Error while deleting group, please try again", "error_editing_group": "Error while editing group, please try again", "error_reading_file": "Error reading file", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 72d0a0d..61a43b6 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -296,6 +296,12 @@ abstract class AppLocalizations { /// **'Error while creating group, please try again'** String get error_creating_group; + /// Error message when game deletion fails + /// + /// In en, this message translates to: + /// **'Error while deleting game, please try again'** + String get error_deleting_game; + /// Error message when group deletion fails /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index e417f18..77c895e 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -112,6 +112,10 @@ class AppLocalizationsDe extends AppLocalizations { String get error_creating_group => 'Fehler beim Erstellen der Gruppe, bitte erneut versuchen'; + @override + String get error_deleting_game => + 'Fehler beim Löschen der Spielvorlage, bitte erneut versuchen'; + @override String get error_deleting_group => 'Fehler beim Löschen der Gruppe, bitte erneut versuchen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index cea3a39..3e96afa 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -112,6 +112,10 @@ class AppLocalizationsEn extends AppLocalizations { String get error_creating_group => 'Error while creating group, please try again'; + @override + String get error_deleting_game => + 'Error while deleting game, please try again'; + @override String get error_deleting_group => 'Error while deleting group, please try again'; diff --git a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart index 8514bd6..83de0da 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart @@ -37,12 +37,18 @@ class _ChooseGameViewState extends State { /// Controller for the search bar final TextEditingController searchBarController = TextEditingController(); - /// Currently selected game index + /// Currently selected game id late String selectedGameId; + /// Games filtered according to the current search query + late List filteredGames; + @override void initState() { selectedGameId = widget.initialSelectedGameId; + + // Start with all games visible + filteredGames = List.from(widget.games); super.initState(); } @@ -63,7 +69,7 @@ class _ChooseGameViewState extends State { IconButton( icon: const Icon(Icons.add), onPressed: () async { - Game? newGame = await Navigator.push( + final result = await Navigator.push( context, adaptivePageRoute( builder: (context) => CreateGameView( @@ -73,10 +79,11 @@ class _ChooseGameViewState extends State { ), ), ); - if (newGame != null) { + if (result != null && result.game != null) { setState(() { - widget.games.insert(0, newGame); + widget.games.insert(0, result.game); }); + _refreshFromSource(); } }, ), @@ -100,46 +107,61 @@ class _ChooseGameViewState extends State { child: CustomSearchBar( controller: searchBarController, hintText: loc.game_name, + onChanged: (value) { + _applySearchFilter(value); + }, ), ), const SizedBox(height: 5), Expanded( child: ListView.builder( - itemCount: widget.games.length, + itemCount: filteredGames.length, itemBuilder: (BuildContext context, int index) { + final game = filteredGames[index]; return TitleDescriptionListTile( - title: widget.games[index].name, - description: widget.games[index].description, - badgeText: translateRulesetToString( - widget.games[index].ruleset, - context, - ), - isHighlighted: selectedGameId == widget.games[index].id, + title: game.name, + description: game.description, + badgeText: translateRulesetToString(game.ruleset, context), + isHighlighted: selectedGameId == game.id, onTap: () async { setState(() { - if (selectedGameId == widget.games[index].id) { + if (selectedGameId == game.id) { selectedGameId = ''; } else { - selectedGameId = widget.games[index].id; + selectedGameId = game.id; } }); }, onLongPress: () async { - Game? newGame = await Navigator.push( + final result = await Navigator.push( context, adaptivePageRoute( builder: (context) => CreateGameView( - gameToEdit: widget.games[index], + gameToEdit: game, onGameCreatedOrEdited: () { widget.onGamesUpdated?.call(); }, ), ), ); - if (newGame != null) { - setState(() { - widget.games[index] = newGame; - }); + if (result != null && result.game != null) { + // Find the index in the original list to mutate + final originalIndex = widget.games.indexWhere( + (g) => g.id == game.id, + ); + if (originalIndex == -1) { + return; + } + if (result.delete) { + setState(() { + widget.games.removeAt(originalIndex); + }); + } else { + setState(() { + widget.games[originalIndex] = result.game; + }); + } + _refreshFromSource(); } }, ); @@ -151,4 +173,28 @@ class _ChooseGameViewState extends State { ), ); } + + /// Applies the search filter to the games list based on [query]. + void _applySearchFilter(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) { + setState(() { + filteredGames = List.from(widget.games); + }); + return; + } + + setState(() { + filteredGames = widget.games.where((game) { + final name = game.name.toLowerCase(); + final description = game.description.toLowerCase(); + return name.contains(q) || description.contains(q); + }).toList(); + }); + } + + /// Re-applies the current filter after the underlying games list changed. + void _refreshFromSource() { + _applySearchFilter(searchBarController.text); + } } diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart index 6635978..498fc4c 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart @@ -18,9 +18,13 @@ import 'package:tallee/presentation/widgets/player_selection.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; +/// A stateful widget for creating or editing a match. class CreateMatchView extends StatefulWidget { - /// A view that allows creating a new match - /// [onWinnerChanged]: Optional callback invoked when the winner is changed + /// Constructor for `CreateMatchView`. + /// + /// [onWinnerChanged] is an optional callback invoked when the winner is changed. + /// [matchToEdit] is an optional match to prefill the fields. + /// [onMatchUpdated] is an optional callback invoked when the match is updated. const CreateMatchView({ super.key, this.onWinnerChanged, @@ -28,47 +32,49 @@ class CreateMatchView extends StatefulWidget { this.onMatchUpdated, }); - /// Optional callback invoked when the winner is changed + /// Optional callback invoked when the winner is changed. final VoidCallback? onWinnerChanged; - /// Optional callback invoked when the match is updated + /// Optional callback invoked when the match is updated. final void Function(Match)? onMatchUpdated; - /// An optional match to prefill the fields + /// An optional match to prefill the fields. final Match? matchToEdit; @override State createState() => _CreateMatchViewState(); } +/// The state class for `CreateMatchView`, managing the UI and logic for creating or editing a match. class _CreateMatchViewState extends State { + /// The database instance for accessing match data. late final AppDatabase db; - /// Controller for the match name input field + /// Controller for the match name input field. final TextEditingController _matchNameController = TextEditingController(); - /// Hint text for the match name input field + /// Hint text for the match name input field. String? hintText; - /// List of all groups from the database + /// List of all games from the database. List gamesList = []; - /// List of all groups from the database + /// List of all groups from the database. List groupsList = []; - /// List of all players from the database + /// List of all players from the database. List playerList = []; - /// The currently selected group + /// The currently selected group. Group? selectedGroup; - /// The currently selected game + /// The currently selected game. Game? selectedGame; - /// The currently selected players + /// The currently selected players. List selectedPlayers = []; - /// GlobalKey for ScaffoldMessenger to show snackbars + /// GlobalKey for ScaffoldMessenger to show snackbars. final _scaffoldMessengerKey = GlobalKey(); @override @@ -80,6 +86,7 @@ class _CreateMatchViewState extends State { db = Provider.of(context, listen: false); + // Load games, groups, and players from the database. Future.wait([ db.gameDao.getAllGames(), db.groupDao.getAllGroups(), @@ -90,7 +97,7 @@ class _CreateMatchViewState extends State { groupsList = result[1] as List; playerList = result[2] as List; - // If a match is provided, prefill the fields + // If a match is provided, prefill the fields. if (widget.matchToEdit != null) { prefillMatchDetails(); } @@ -130,6 +137,7 @@ class _CreateMatchViewState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ + // Match name input field. Container( margin: CustomTheme.tileMargin, child: TextInputField( @@ -138,6 +146,7 @@ class _CreateMatchViewState extends State { maxLength: Constants.MAX_MATCH_NAME_LENGTH, ), ), + // Game selection tile. ChooseTile( title: loc.game, trailingText: selectedGame == null @@ -172,15 +181,13 @@ class _CreateMatchViewState extends State { }); }, ), + // Group selection tile. ChooseTile( title: loc.group, trailingText: selectedGroup == null ? loc.none_group : selectedGroup!.name, onPressed: () async { - // Remove all players from the previously selected group from - // the selected players list, in case the user deselects the - // group or selects a different group. selectedPlayers.removeWhere( (player) => selectedGroup?.members.any( @@ -207,6 +214,7 @@ class _CreateMatchViewState extends State { }); }, ), + // Player selection widget. Expanded( child: PlayerSelection( key: ValueKey(selectedGroup?.id ?? 'no_group'), @@ -219,6 +227,7 @@ class _CreateMatchViewState extends State { }, ), ), + // Create or save button. CustomWidthButton( text: buttonText, sizeRelativeToWidth: 0.95, @@ -240,16 +249,16 @@ class _CreateMatchViewState extends State { /// /// Returns `true` if: /// - A ruleset is selected AND - /// - Either a group is selected OR at least 2 players are selected + /// - Either a group is selected OR at least 2 players are selected. bool _enableCreateGameButton() { return (selectedGroup != null || (selectedPlayers.length > 1) && selectedGame != null); } - // If a match was provided to the view, it updates the match in the database - // and navigates back to the previous screen. - // If no match was provided, it creates a new match in the database and - // navigates to the MatchResultView for the newly created match. + /// Handles navigation when the create or save button is pressed. + /// + /// If a match is being edited, updates the match in the database. + /// Otherwise, creates a new match and navigates to the MatchResultView. void buttonNavigation(BuildContext context) async { if (widget.matchToEdit != null) { await updateMatch(); @@ -274,8 +283,7 @@ class _CreateMatchViewState extends State { } } - /// Updates attributes of the existing match in the database based on the - /// changes made in the edit view. + /// Updates the existing match in the database. Future updateMatch() async { final updatedMatch = Match( id: widget.matchToEdit!.id, @@ -312,7 +320,6 @@ class _CreateMatchViewState extends State { ); } - // Add players who are in updatedMatch but not in the original match for (var player in updatedMatch.players) { if (!widget.matchToEdit!.players.any((p) => p.id == player.id)) { await db.playerMatchDao.addPlayerToMatch( @@ -322,7 +329,6 @@ class _CreateMatchViewState extends State { } } - // Remove players who are in the original match but not in updatedMatch for (var player in widget.matchToEdit!.players) { if (!updatedMatch.players.any((p) => p.id == player.id)) { await db.playerMatchDao.removePlayerFromMatch( @@ -338,8 +344,7 @@ class _CreateMatchViewState extends State { widget.onMatchUpdated?.call(updatedMatch); } - // Creates a new match and adds it to the database. - // Returns the created match. + /// Creates a new match and adds it to the database. Future createMatch() async { Match match = Match( name: _matchNameController.text.isEmpty @@ -354,7 +359,7 @@ class _CreateMatchViewState extends State { return match; } - // If a match was provided to the view, this method prefills the input fields + /// Prefills the input fields if a match was provided to the view. void prefillMatchDetails() { final match = widget.matchToEdit!; _matchNameController.text = match.name; @@ -366,8 +371,7 @@ class _CreateMatchViewState extends State { } } - // If none of the selected players are from the currently selected group, - // the group is also deselected. + /// Removes the group if none of its members are in the selected players list. Future removeGroupWhenNoMemberLeft() async { if (selectedGroup == null) return; @@ -381,7 +385,7 @@ class _CreateMatchViewState extends State { } } - // Loads all games from the database and updates the gamesList. + /// Loads all games from the database and updates the state. Future loadGames() async { final result = await db.gameDao.getAllGames(); result.sort((a, b) => b.createdAt.compareTo(a.createdAt)); diff --git a/lib/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart index cbe224c..64c3f84 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart @@ -7,13 +7,18 @@ import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/dto/game.dart'; +import 'package:tallee/data/dto/group.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/game_view/choose_color_view.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/game_view/choose_ruleset_view.dart'; import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; +/// A stateful widget for creating or editing a game. +/// - [gameToEdit] An optional game to prefill the fields +/// - [onGameCreatedOrEdited] Callback to invoke when the game is created or edited class CreateGameView extends StatefulWidget { const CreateGameView({ super.key, @@ -32,16 +37,39 @@ class CreateGameView extends StatefulWidget { } class _CreateGameViewState extends State { + /// GlobalKey for ScaffoldMessenger to show snackbars + final _scaffoldMessengerKey = GlobalKey(); + + /// The database instance for accessing game data. late final AppDatabase db; + + /// The currently selected ruleset for the game. Ruleset? selectedRuleset; + + /// The index of the currently selected ruleset. int selectedRulesetIndex = -1; + + /// A list of available rulesets and their localized names. late List<(Ruleset, String)> _rulesets; + /// The currently selected color for the game. GameColor? selectedColor; + /// Controller for the game name input field. final _gameNameController = TextEditingController(); + + /// Controller for the game description input field. final _descriptionController = TextEditingController(); + /// The ID of the currently selected group. + late String selectedGroupId; + + /// A controller for the search bar input field. + final TextEditingController controller = TextEditingController(); + + /// A list of groups filtered based on the search query. + late final List filteredGroups; + @override void initState() { super.initState(); @@ -104,6 +132,51 @@ class _CreateGameViewState extends State { backgroundColor: CustomTheme.backgroundColor, appBar: AppBar( title: Text(isEditing ? loc.edit_game : loc.create_game), + actions: widget.gameToEdit == null + ? [] + : [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + if (widget.gameToEdit != null) { + showDialog( + context: context, + builder: (context) => CustomAlertDialog( + title: loc.delete_game, + content: loc.this_cannot_be_undone, + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(false), + child: Text(loc.cancel), + ), + TextButton( + onPressed: () => + Navigator.of(context).pop(true), + child: Text(loc.delete), + ), + ], + ), + ).then((confirmed) async { + if (confirmed == true && context.mounted) { + bool success = await db.gameDao.deleteGame( + gameId: widget.gameToEdit!.id, + ); + if (!context.mounted) return; + if (success) { + Navigator.of( + context, + ).pop((game: widget.gameToEdit, delete: true)); + } else { + if (!mounted) return; + showSnackbar(message: loc.error_deleting_game); + } + } + }); + } + }, + ), + ], ), body: SafeArea( child: Column( @@ -196,7 +269,9 @@ class _CreateGameViewState extends State { } widget.onGameCreatedOrEdited.call(); if (context.mounted) { - Navigator.of(context).pop(newGame); + Navigator.of( + context, + ).pop((game: newGame, delete: false)); } } : null, @@ -209,6 +284,9 @@ class _CreateGameViewState extends State { ); } + /// Handles updating an existing game in the database. + /// + /// [newGame] The updated game object. Future handleGameUpdate(Game newGame) async { final oldGame = widget.gameToEdit!; @@ -248,7 +326,26 @@ class _CreateGameViewState extends State { } } + /// Handles creating a new game in the database. + /// + /// [newGame] The game object to be created. Future handleGameCreation(Game newGame) async { await db.gameDao.addGame(game: newGame); } + + /// Displays a snackbar with the given message and optional action. + /// + /// [message] The message to display in the snackbar. + void showSnackbar({required String message}) { + final messenger = _scaffoldMessengerKey.currentState; + if (messenger != null) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(message, style: const TextStyle(color: Colors.white)), + backgroundColor: CustomTheme.boxColor, + ), + ); + } + } } diff --git a/lib/presentation/views/main_menu/match_view/match_view.dart b/lib/presentation/views/main_menu/match_view/match_view.dart index a090b46..96a17b7 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -19,7 +19,7 @@ import 'package:tallee/presentation/widgets/tiles/match_tile.dart'; import 'package:tallee/presentation/widgets/top_centered_message.dart'; class MatchView extends StatefulWidget { - /// A view that displays a list of matches + /// A view that displays a list of matches. const MatchView({super.key}); @override @@ -27,11 +27,14 @@ class MatchView extends StatefulWidget { } class _MatchViewState extends State { + /// Database instance used to access match data. late final AppDatabase db; + + /// Indicates whether matches are currently being loaded. bool isLoading = true; /// Loaded matches from the database, - /// initially filled with skeleton matches + /// initially filled with skeleton matches. List matches = List.filled( 4, Match(