From 2f5b9e5ff2a6ac9738bb37a0082132ec97631b3e Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Tue, 28 Apr 2026 15:27:52 +0200 Subject: [PATCH] Cherry picked changes from 119-implementierung-der-games --- lib/core/common.dart | 47 ++- lib/core/constants.dart | 3 + lib/data/models/game.dart | 11 +- lib/l10n/arb/app_de.arb | 15 + lib/l10n/arb/app_en.arb | 36 ++ lib/l10n/generated/app_localizations.dart | 90 +++++ lib/l10n/generated/app_localizations_de.dart | 46 +++ lib/l10n/generated/app_localizations_en.dart | 46 +++ .../create_match/choose_game_view.dart | 123 +++++- .../create_game/choose_color_view.dart | 78 ++++ .../create_game/choose_ruleset_view.dart | 99 +++++ .../create_game/create_game_view.dart | 352 ++++++++++++++++++ .../create_match/create_match_view.dart | 31 +- .../widgets/text_input/text_input_field.dart | 21 +- .../tiles/title_description_list_tile.dart | 14 +- 15 files changed, 978 insertions(+), 34 deletions(-) create mode 100644 lib/presentation/views/main_menu/match_view/create_match/create_game/choose_color_view.dart create mode 100644 lib/presentation/views/main_menu/match_view/create_match/create_game/choose_ruleset_view.dart create mode 100644 lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart diff --git a/lib/core/common.dart b/lib/core/common.dart index 8027180..4c02350 100644 --- a/lib/core/common.dart +++ b/lib/core/common.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/player.dart'; @@ -21,6 +21,51 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) { } } +/// Translates a [GameColor] enum value to its corresponding localized string. +String translateGameColorToString(GameColor color, BuildContext context) { + final loc = AppLocalizations.of(context); + switch (color) { + case GameColor.red: + return loc.color_red; + case GameColor.blue: + return loc.color_blue; + case GameColor.green: + return loc.color_green; + case GameColor.yellow: + return loc.color_yellow; + case GameColor.purple: + return loc.color_purple; + case GameColor.orange: + return loc.color_orange; + case GameColor.pink: + return loc.color_pink; + case GameColor.teal: + return loc.color_teal; + } +} + +/// Returns the [Color] object corresponding to a [GameColor] enum value. +Color getColorFromGameColor(GameColor color) { + switch (color) { + case GameColor.red: + return Colors.red; + case GameColor.blue: + return Colors.blue; + case GameColor.green: + return Colors.green; + case GameColor.yellow: + return Colors.yellow; + case GameColor.purple: + return Colors.purple; + case GameColor.orange: + return Colors.orange; + case GameColor.pink: + return Colors.pink; + case GameColor.teal: + return Colors.teal; + } +} + /// Counts how many players in the match are not part of the group /// Returns the count as a string, or an empty string if there is no group String getExtraPlayerCount(Match match) { diff --git a/lib/core/constants.dart b/lib/core/constants.dart index c1bc0fe..86e3ad7 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -19,4 +19,7 @@ class Constants { /// Maximum length for team names static const int MAX_TEAM_NAME_LENGTH = 32; + + /// Maximum length for game descriptions + static const int MAX_GAME_DESCRIPTION_LENGTH = 256; } diff --git a/lib/data/models/game.dart b/lib/data/models/game.dart index 607db0a..4888df4 100644 --- a/lib/data/models/game.dart +++ b/lib/data/models/game.dart @@ -12,16 +12,17 @@ class Game { final String icon; Game({ - String? id, - DateTime? createdAt, required this.name, required this.ruleset, - String? description, required this.color, - required this.icon, + String? id, + DateTime? createdAt, + String? description, + String? icon, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(), - description = description ?? ''; + description = description ?? '', + icon = icon ?? ''; @override String toString() { diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 46c780a..ba4fe38 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -6,10 +6,21 @@ "app_name": "Tallee", "best_player": "Beste:r Spieler:in", "cancel": "Abbrechen", + "choose_color": "Farbe wählen", "choose_game": "Spielvorlage wählen", "choose_group": "Gruppe wählen", "choose_ruleset": "Regelwerk wählen", + "color": "Farbe", + "color_blue": "Blau", + "color_green": "Grün", + "color_orange": "Orange", + "color_pink": "Rosa", + "color_purple": "Lila", + "color_red": "Rot", + "color_teal": "Türkis", + "color_yellow": "Gelb", "could_not_add_player": "Spieler:in {playerName} konnte nicht hinzugefügt werden", + "create_game": "Spielvorlage erstellen", "create_group": "Gruppe erstellen", "create_match": "Spiel erstellen", "create_new_group": "Neue Gruppe erstellen", @@ -22,13 +33,17 @@ "days_ago": "vor {count} Tagen", "delete": "Löschen", "delete_all_data": "Alle Daten löschen", + "delete_game": "Spielvorlage löschen", "delete_group": "Gruppe löschen", "delete_match": "Spiel löschen", + "description": "Beschreibung", + "edit_game": "Spielvorlage bearbeiten", "edit_group": "Gruppe bearbeiten", "edit_match": "Gruppe bearbeiten", "enter_points": "Punkte eingeben", "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 a85e1b0..98d1c38 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -18,6 +18,9 @@ "@cancel": { "description": "Cancel button text" }, + "@choose_color": { + "description": "Label for choosing a color" + }, "@choose_game": { "description": "Label for choosing a game" }, @@ -27,9 +30,15 @@ "@choose_ruleset": { "description": "Label for choosing a ruleset" }, + "@color": { + "description": "Color label" + }, "@could_not_add_player": { "description": "Error message when adding a player fails" }, + "@create_game": { + "description": "Button text to create a game" + }, "@create_group": { "description": "Button text to create a group" }, @@ -71,12 +80,21 @@ "@delete_all_data": { "description": "Confirmation dialog for deleting all data" }, + "@delete_game": { + "description": "Button text to delete a game" + }, "@delete_group": { "description": "Confirmation dialog for deleting a group" }, "@delete_match": { "description": "Button text to delete a match" }, + "@description": { + "description": "Description label" + }, + "@edit_game": { + "description": "Button text to edit a game" + }, "@edit_group": { "description": "Button & Appbar label for editing a group" }, @@ -92,6 +110,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" }, @@ -340,10 +361,21 @@ "app_name": "Tallee", "best_player": "Best Player", "cancel": "Cancel", + "choose_color": "Choose Color", "choose_game": "Choose Game", "choose_group": "Choose Group", "choose_ruleset": "Choose Ruleset", + "color": "Color", + "color_blue": "Blue", + "color_green": "Green", + "color_orange": "Orange", + "color_pink": "Pink", + "color_purple": "Purple", + "color_red": "Red", + "color_teal": "Teal", + "color_yellow": "Yellow", "could_not_add_player": "Could not add player", + "create_game": "Create Game", "create_group": "Create Group", "create_match": "Create match", "create_new_group": "Create new group", @@ -356,13 +388,17 @@ "days_ago": "{count} days ago", "delete": "Delete", "delete_all_data": "Delete all data", + "delete_game": "Delete Game", "delete_group": "Delete Group", "delete_match": "Delete Match", + "description": "Description", + "edit_game": "Edit Game", "edit_group": "Edit Group", "edit_match": "Edit Match", "enter_points": "Enter points", "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 99c9317..8e44e7b 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -134,6 +134,12 @@ abstract class AppLocalizations { /// **'Cancel'** String get cancel; + /// Label for choosing a color + /// + /// In en, this message translates to: + /// **'Choose Color'** + String get choose_color; + /// Label for choosing a game /// /// In en, this message translates to: @@ -152,12 +158,72 @@ abstract class AppLocalizations { /// **'Choose Ruleset'** String get choose_ruleset; + /// Color label + /// + /// In en, this message translates to: + /// **'Color'** + String get color; + + /// No description provided for @color_blue. + /// + /// In en, this message translates to: + /// **'Blue'** + String get color_blue; + + /// No description provided for @color_green. + /// + /// In en, this message translates to: + /// **'Green'** + String get color_green; + + /// No description provided for @color_orange. + /// + /// In en, this message translates to: + /// **'Orange'** + String get color_orange; + + /// No description provided for @color_pink. + /// + /// In en, this message translates to: + /// **'Pink'** + String get color_pink; + + /// No description provided for @color_purple. + /// + /// In en, this message translates to: + /// **'Purple'** + String get color_purple; + + /// No description provided for @color_red. + /// + /// In en, this message translates to: + /// **'Red'** + String get color_red; + + /// No description provided for @color_teal. + /// + /// In en, this message translates to: + /// **'Teal'** + String get color_teal; + + /// No description provided for @color_yellow. + /// + /// In en, this message translates to: + /// **'Yellow'** + String get color_yellow; + /// Error message when adding a player fails /// /// In en, this message translates to: /// **'Could not add player'** String could_not_add_player(Object playerName); + /// Button text to create a game + /// + /// In en, this message translates to: + /// **'Create Game'** + String get create_game; + /// Button text to create a group /// /// In en, this message translates to: @@ -230,6 +296,12 @@ abstract class AppLocalizations { /// **'Delete all data'** String get delete_all_data; + /// Button text to delete a game + /// + /// In en, this message translates to: + /// **'Delete Game'** + String get delete_game; + /// Confirmation dialog for deleting a group /// /// In en, this message translates to: @@ -242,6 +314,18 @@ abstract class AppLocalizations { /// **'Delete Match'** String get delete_match; + /// Description label + /// + /// In en, this message translates to: + /// **'Description'** + String get description; + + /// Button text to edit a game + /// + /// In en, this message translates to: + /// **'Edit Game'** + String get edit_game; + /// Button & Appbar label for editing a group /// /// In en, this message translates to: @@ -272,6 +356,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 51b4c62..3c2b4e3 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -26,6 +26,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cancel => 'Abbrechen'; + @override + String get choose_color => 'Farbe wählen'; + @override String get choose_game => 'Spielvorlage wählen'; @@ -35,11 +38,41 @@ class AppLocalizationsDe extends AppLocalizations { @override String get choose_ruleset => 'Regelwerk wählen'; + @override + String get color => 'Farbe'; + + @override + String get color_blue => 'Blau'; + + @override + String get color_green => 'Grün'; + + @override + String get color_orange => 'Orange'; + + @override + String get color_pink => 'Rosa'; + + @override + String get color_purple => 'Lila'; + + @override + String get color_red => 'Rot'; + + @override + String get color_teal => 'Türkis'; + + @override + String get color_yellow => 'Gelb'; + @override String could_not_add_player(Object playerName) { return 'Spieler:in $playerName konnte nicht hinzugefügt werden'; } + @override + String get create_game => 'Spielvorlage erstellen'; + @override String get create_group => 'Gruppe erstellen'; @@ -78,12 +111,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get delete_all_data => 'Alle Daten löschen'; + @override + String get delete_game => 'Spielvorlage löschen'; + @override String get delete_group => 'Gruppe löschen'; @override String get delete_match => 'Spiel löschen'; + @override + String get description => 'Beschreibung'; + + @override + String get edit_game => 'Spielvorlage bearbeiten'; + @override String get edit_group => 'Gruppe bearbeiten'; @@ -100,6 +142,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 2b42e47..e14b7a0 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -26,6 +26,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancel => 'Cancel'; + @override + String get choose_color => 'Choose Color'; + @override String get choose_game => 'Choose Game'; @@ -35,11 +38,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get choose_ruleset => 'Choose Ruleset'; + @override + String get color => 'Color'; + + @override + String get color_blue => 'Blue'; + + @override + String get color_green => 'Green'; + + @override + String get color_orange => 'Orange'; + + @override + String get color_pink => 'Pink'; + + @override + String get color_purple => 'Purple'; + + @override + String get color_red => 'Red'; + + @override + String get color_teal => 'Teal'; + + @override + String get color_yellow => 'Yellow'; + @override String could_not_add_player(Object playerName) { return 'Could not add player'; } + @override + String get create_game => 'Create Game'; + @override String get create_group => 'Create Group'; @@ -78,12 +111,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get delete_all_data => 'Delete all data'; + @override + String get delete_game => 'Delete Game'; + @override String get delete_group => 'Delete Group'; @override String get delete_match => 'Delete Match'; + @override + String get description => 'Description'; + + @override + String get edit_game => 'Edit Game'; + @override String get edit_group => 'Edit Group'; @@ -100,6 +142,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 51512f9..8f3e06e 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 @@ -1,19 +1,23 @@ import 'package:flutter/material.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/game.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart'; import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart'; import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart'; class ChooseGameView extends StatefulWidget { /// A view that allows the user to choose a game from a list of available games - /// - [games]: A list of tuples containing the game name, description and ruleset - /// - [initialGameIndex]: The index of the initially selected game + /// - [games]: The list of available games + /// - [initialGameId]: The id of the initially selected game + /// - [onGamesUpdated]: Optional callback invoked when the games are updated const ChooseGameView({ super.key, required this.games, required this.initialGameId, + this.onGamesUpdated, }); /// A list of tuples containing the game name, description and ruleset @@ -22,6 +26,9 @@ class ChooseGameView extends StatefulWidget { /// The id of the initially selected game final String initialGameId; + /// Optional callback invoked when the games are updated + final VoidCallback? onGamesUpdated; + @override State createState() => _ChooseGameViewState(); } @@ -33,9 +40,16 @@ class _ChooseGameViewState extends State { /// Currently selected game index late String selectedGameId; + /// Games filtered according to the current search query + late List filteredGames; + @override void initState() { selectedGameId = widget.initialGameId; + + // Start with all games visible + filteredGames = List.from(widget.games); + super.initState(); } @@ -58,6 +72,30 @@ class _ChooseGameViewState extends State { ); }, ), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () async { + final result = await Navigator.push( + context, + adaptivePageRoute( + builder: (context) => CreateGameView( + onGameChanged: () { + widget.onGamesUpdated?.call(); + }, + ), + ), + ); + if (result != null && result.game != null) { + setState(() { + widget.games.insert(0, result.game); + }); + _refreshFromSource(); + } + }, + ), + ], + title: Text(loc.choose_game), ), body: PopScope( @@ -77,30 +115,63 @@ 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, - onPressed: () async { + title: game.name, + description: game.description, + badgeText: translateRulesetToString(game.ruleset, context), + isHighlighted: selectedGameId == game.id, + onTap: () async { setState(() { - if (selectedGameId != widget.games[index].id) { - selectedGameId = widget.games[index].id; - } else { + if (selectedGameId == game.id) { selectedGameId = ''; + } else { + selectedGameId = game.id; } }); }, + onLongPress: () async { + final result = await Navigator.push( + context, + adaptivePageRoute( + builder: (context) => CreateGameView( + gameToEdit: game, + onGameChanged: () { + widget.onGamesUpdated?.call(); + }, + ), + ), + ); + 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(); + } + }, ); }, ), @@ -110,4 +181,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_game/choose_color_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_game/choose_color_view.dart new file mode 100644 index 0000000..bf764ad --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/create_game/choose_color_view.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart'; + +class ChooseColorView extends StatefulWidget { + /// A view that allows the user to choose a color from a list of available game colors + /// - [initialColor]: The initially selected color + const ChooseColorView({super.key, this.initialColor}); + + /// The initially selected color + final GameColor? initialColor; + + @override + State createState() => _ChooseColorViewState(); +} + +class _ChooseColorViewState extends State { + /// Currently selected color + GameColor? selectedColor; + + @override + void initState() { + selectedColor = widget.initialColor; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + const colors = GameColor.values; + + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: () { + Navigator.of(context).pop(selectedColor); + }, + ), + title: Text(loc.choose_color), + ), + body: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) return; + Navigator.of(context).pop(selectedColor); + }, + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 85), + itemCount: colors.length, + itemBuilder: (BuildContext context, int index) { + final color = colors[index]; + return TitleDescriptionListTile( + onTap: () { + setState(() { + if (selectedColor == color) { + selectedColor = null; + } else { + selectedColor = color; + } + }); + }, + title: translateGameColorToString(color, context), + description: '', + isHighlighted: selectedColor == color, + badgeText: ' ', //Breite für Color Badge + badgeColor: getColorFromGameColor(color), + ); + }, + ), + ), + ); + } +} diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_game/choose_ruleset_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_game/choose_ruleset_view.dart new file mode 100644 index 0000000..6b69b22 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/create_game/choose_ruleset_view.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart'; + +class ChooseRulesetView extends StatefulWidget { + /// A view that allows the user to choose a ruleset from a list of available rulesets + /// - [rulesets]: A list of tuples containing the ruleset and its description + /// - [initialRulesetIndex]: The index of the initially selected ruleset + const ChooseRulesetView({ + super.key, + required this.rulesets, + required this.initialRulesetIndex, + }); + + /// A list of tuples containing the ruleset and its description + final List<(Ruleset, String)> rulesets; + + /// The index of the initially selected ruleset + final int initialRulesetIndex; + @override + State createState() => _ChooseRulesetViewState(); +} + +class _ChooseRulesetViewState extends State { + /// Currently selected ruleset index + late int selectedRulesetIndex; + + @override + void initState() { + selectedRulesetIndex = widget.initialRulesetIndex; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return DefaultTabController( + length: 2, + initialIndex: 0, + child: Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: () { + Navigator.of(context).pop( + selectedRulesetIndex == -1 + ? null + : widget.rulesets[selectedRulesetIndex].$1, + ); + }, + ), + title: Text(loc.choose_ruleset), + ), + body: PopScope( + // This fixes that the Android Back Gesture didn't return the + // selectedRulesetIndex and therefore the selected Ruleset wasn't saved + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + return; + } + Navigator.of(context).pop( + selectedRulesetIndex == -1 + ? null + : widget.rulesets[selectedRulesetIndex].$1, + ); + }, + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 85), + itemCount: widget.rulesets.length, + itemBuilder: (BuildContext context, int index) { + return TitleDescriptionListTile( + onTap: () async { + setState(() { + if (selectedRulesetIndex == index) { + selectedRulesetIndex = -1; + } else { + selectedRulesetIndex = index; + } + }); + }, + title: translateRulesetToString( + widget.rulesets[index].$1, + context, + ), + description: widget.rulesets[index].$2, + isHighlighted: selectedRulesetIndex == index, + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart new file mode 100644 index 0000000..2907720 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tallee/core/adaptive_page_route.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/constants.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/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game/choose_color_view.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game/choose_ruleset_view.dart'; +import 'package:tallee/presentation/widgets/buttons/custom_width_button.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/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 +/// - [onGameChanged] Callback to invoke when the game is created or edited +class CreateGameView extends StatefulWidget { + const CreateGameView({ + super.key, + this.gameToEdit, + required this.onGameChanged, + }); + + /// An optional game to prefill the fields + final Game? gameToEdit; + + /// Callback to invoke when the game is created or edited + final VoidCallback onGameChanged; + + @override + State createState() => _CreateGameViewState(); +} + +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(); + db = Provider.of(context, listen: false); + _gameNameController.addListener(() => setState(() {})); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _rulesets = [ + ( + Ruleset.singleWinner, + translateRulesetToString(Ruleset.singleWinner, context), + ), + ( + Ruleset.singleLoser, + translateRulesetToString(Ruleset.singleLoser, context), + ), + ( + Ruleset.highestScore, + translateRulesetToString(Ruleset.highestScore, context), + ), + ( + Ruleset.lowestScore, + translateRulesetToString(Ruleset.lowestScore, context), + ), + ( + Ruleset.multipleWinners, + translateRulesetToString(Ruleset.multipleWinners, context), + ), + ]; + + if (widget.gameToEdit != null) { + _gameNameController.text = widget.gameToEdit!.name; + _descriptionController.text = widget.gameToEdit!.description; + selectedRuleset = widget.gameToEdit!.ruleset; + selectedColor = widget.gameToEdit!.color; + + selectedRulesetIndex = _rulesets.indexWhere( + (r) => r.$1 == selectedRuleset, + ); + } + } + + @override + void dispose() { + _gameNameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var loc = AppLocalizations.of(context); + final isEditing = widget.gameToEdit != null; + + return ScaffoldMessenger( + child: Scaffold( + 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: Text(loc.this_cannot_be_undone), + actions: [ + CustomDialogAction( + onPressed: () => + Navigator.of(context).pop(false), + text: loc.cancel, + ), + CustomDialogAction( + onPressed: () => + Navigator.of(context).pop(true), + 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) { + widget.onGameChanged.call(); + Navigator.of( + context, + ).pop((game: widget.gameToEdit, delete: true)); + } else { + if (!mounted) return; + showSnackbar(message: loc.error_deleting_game); + } + } + }); + } + }, + ), + ], + ), + body: SafeArea( + child: Column( + children: [ + Container( + margin: CustomTheme.tileMargin, + child: TextInputField( + controller: _gameNameController, + maxLength: Constants.MAX_MATCH_NAME_LENGTH, + hintText: loc.game_name, + ), + ), + ChooseTile( + title: loc.ruleset, + trailingText: selectedRuleset == null + ? loc.none + : translateRulesetToString(selectedRuleset!, context), + onPressed: () async { + final result = await Navigator.of(context).push( + adaptivePageRoute( + builder: (context) => ChooseRulesetView( + rulesets: _rulesets, + initialRulesetIndex: selectedRulesetIndex, + ), + ), + ); + if (mounted) { + setState(() { + selectedRuleset = result; + selectedRulesetIndex = result == null + ? -1 + : _rulesets.indexWhere((r) => r.$1 == result); + }); + } + }, + ), + ChooseTile( + title: loc.color, + trailingText: selectedColor == null + ? loc.none + : translateGameColorToString(selectedColor!, context), + onPressed: () async { + final result = await Navigator.of(context).push( + adaptivePageRoute( + builder: (context) => + ChooseColorView(initialColor: selectedColor), + ), + ); + if (mounted) { + setState(() { + selectedColor = result; + }); + } + }, + ), + Container( + margin: CustomTheme.tileMargin, + child: TextInputField( + controller: _descriptionController, + hintText: loc.description, + minLines: 6, + maxLines: 6, + maxLength: Constants.MAX_GAME_DESCRIPTION_LENGTH, + showCounterText: true, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(12.0), + child: CustomWidthButton( + text: isEditing ? loc.edit_game : loc.create_game, + sizeRelativeToWidth: 1, + buttonType: ButtonType.primary, + onPressed: + _gameNameController.text.trim().isNotEmpty && + selectedRulesetIndex != -1 && + selectedColor != null + ? () async { + Game newGame = Game( + name: _gameNameController.text.trim(), + description: _descriptionController.text.trim(), + ruleset: selectedRuleset!, + color: selectedColor!, + ); + if (isEditing) { + await handleGameUpdate(newGame); + } else { + await handleGameCreation(newGame); + } + widget.onGameChanged.call(); + if (context.mounted) { + Navigator.of( + context, + ).pop((game: newGame, delete: false)); + } + } + : null, + ), + ), + ], + ), + ), + ), + ); + } + + /// Handles updating an existing game in the database. + /// + /// [newGame] The updated game object. + Future handleGameUpdate(Game newGame) async { + final oldGame = widget.gameToEdit!; + + if (oldGame.name != newGame.name) { + await db.gameDao.updateGameName( + gameId: oldGame.id, + newName: newGame.name, + ); + } + + if (oldGame.description != newGame.description) { + await db.gameDao.updateGameDescription( + gameId: oldGame.id, + newDescription: newGame.description, + ); + } + + if (oldGame.ruleset != newGame.ruleset) { + await db.gameDao.updateGameRuleset( + gameId: oldGame.id, + newRuleset: newGame.ruleset, + ); + } + + if (oldGame.color != newGame.color) { + await db.gameDao.updateGameColor( + gameId: oldGame.id, + newColor: newGame.color, + ); + } + + if (oldGame.icon != newGame.icon) { + await db.gameDao.updateGameIcon( + gameId: oldGame.id, + newIcon: newGame.icon, + ); + } + } + + /// 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/create_match/create_match_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart index 1a04c78..b042ebb 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 @@ -115,6 +115,7 @@ class _CreateMatchViewState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ + // Match name input field. Container( margin: CustomTheme.tileMargin, child: TextInputField( @@ -123,6 +124,8 @@ class _CreateMatchViewState extends State { maxLength: Constants.MAX_MATCH_NAME_LENGTH, ), ), + + // Game selection tile. ChooseTile( title: loc.game, trailingText: selectedGame == null @@ -146,6 +149,8 @@ class _CreateMatchViewState extends State { }); }, ), + + // Group selection tile. ChooseTile( title: loc.group, trailingText: selectedGroup == null @@ -181,6 +186,8 @@ class _CreateMatchViewState extends State { }); }, ), + + // Player selection widget. Expanded( child: PlayerSelection( key: ValueKey(selectedGroup?.id ?? 'no_group'), @@ -193,6 +200,8 @@ class _CreateMatchViewState extends State { }, ), ), + + // Create or save button. CustomWidthButton( text: buttonText, sizeRelativeToWidth: 0.95, @@ -218,16 +227,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 (isEditMode()) { await updateMatch(); @@ -252,8 +261,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, @@ -262,7 +270,7 @@ class _CreateMatchViewState extends State { : _matchNameController.text.trim(), group: selectedGroup, players: selectedPlayers, - game: widget.matchToEdit!.game, + game: selectedGame!, createdAt: widget.matchToEdit!.createdAt, endedAt: widget.matchToEdit!.endedAt, notes: widget.matchToEdit!.notes, @@ -282,6 +290,13 @@ class _CreateMatchViewState extends State { ); } + if (widget.matchToEdit!.game.id != updatedMatch.game.id) { + await db.matchDao.updateMatchGame( + matchId: widget.matchToEdit!.id, + gameId: updatedMatch.game.id, + ); + } + // 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)) { diff --git a/lib/presentation/widgets/text_input/text_input_field.dart b/lib/presentation/widgets/text_input/text_input_field.dart index 541ae6f..b074638 100644 --- a/lib/presentation/widgets/text_input/text_input_field.dart +++ b/lib/presentation/widgets/text_input/text_input_field.dart @@ -8,12 +8,18 @@ class TextInputField extends StatelessWidget { /// - [onChanged]: Optional callback invoked when the text in the field changes. /// - [hintText]: The hint text displayed in the text input field when it is empty /// - [maxLength]: Optional parameter for maximum length of the input text. + /// - [maxLines]: The maximum number of lines for the text input field. Defaults to 1. + /// - [minLines]: The minimum number of lines for the text input field. Defaults to 1. + /// - [showCounterText]: Whether to show the counter text in the text input field. Defaults to false. const TextInputField({ super.key, required this.controller, required this.hintText, this.onChanged, this.maxLength, + this.maxLines = 1, + this.minLines = 1, + this.showCounterText = false, }); /// The controller for the text input field. @@ -28,6 +34,15 @@ class TextInputField extends StatelessWidget { /// Optional parameter for maximum length of the input text. final int? maxLength; + /// The maximum number of lines for the text input field. + final int? maxLines; + + /// The minimum number of lines for the text input field. + final int? minLines; + + /// Whether to show the counter text in the text input field. + final bool showCounterText; + @override Widget build(BuildContext context) { return TextField( @@ -35,13 +50,15 @@ class TextInputField extends StatelessWidget { onChanged: onChanged, maxLength: maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, + maxLines: maxLines, + minLines: minLines, + decoration: InputDecoration( filled: true, fillColor: CustomTheme.boxColor, hintText: hintText, hintStyle: const TextStyle(fontSize: 18), - // Hides the character counter - counterText: '', + counterText: showCounterText ? null : '', enabledBorder: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide(color: CustomTheme.boxBorderColor), diff --git a/lib/presentation/widgets/tiles/title_description_list_tile.dart b/lib/presentation/widgets/tiles/title_description_list_tile.dart index 9dc8f33..95163f2 100644 --- a/lib/presentation/widgets/tiles/title_description_list_tile.dart +++ b/lib/presentation/widgets/tiles/title_description_list_tile.dart @@ -5,7 +5,8 @@ class TitleDescriptionListTile extends StatelessWidget { /// A list tile widget that displays a title and description, with optional highlighting and badge. /// - [title]: The title text displayed on the tile. /// - [description]: The description text displayed below the title. - /// - [onPressed]: The callback invoked when the tile is tapped. + /// - [onTap]: The callback invoked when the tile is tapped. + /// - [onLongPress]: The callback invoked when the tile is tapped. /// - [isHighlighted]: A boolean to determine if the tile should be highlighted. /// - [badgeText]: Optional text to display in a badge on the right side of the title. /// - [badgeColor]: Optional color for the badge background. @@ -13,7 +14,8 @@ class TitleDescriptionListTile extends StatelessWidget { super.key, required this.title, required this.description, - this.onPressed, + this.onTap, + this.onLongPress, this.isHighlighted = false, this.badgeText, this.badgeColor, @@ -26,7 +28,10 @@ class TitleDescriptionListTile extends StatelessWidget { final String description; /// The callback invoked when the tile is tapped. - final VoidCallback? onPressed; + final VoidCallback? onTap; + + /// The callback invoked when the tile is long-pressed. + final VoidCallback? onLongPress; /// A boolean to determine if the tile should be highlighted. final bool isHighlighted; @@ -40,7 +45,8 @@ class TitleDescriptionListTile extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: onPressed, + onTap: onTap, + onLongPress: onLongPress, child: AnimatedContainer( margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),