From 7024699a6154394504f056c98c76a9bbdaeade71 Mon Sep 17 00:00:00 2001 From: Mathis Kirchner Date: Sun, 18 Jan 2026 14:38:27 +0100 Subject: [PATCH] implement create game view --- lib/core/constants.dart | 3 + lib/data/dto/game.dart | 23 +++ lib/l10n/arb/app_de.arb | 4 + lib/l10n/arb/app_en.arb | 16 ++ lib/l10n/generated/app_localizations.dart | 24 +++ lib/l10n/generated/app_localizations_de.dart | 12 ++ lib/l10n/generated/app_localizations_en.dart | 12 ++ .../create_match/choose_game_view.dart | 26 +++- .../game_view/choose_ruleset_view.dart | 93 ++++++++++++ .../game_view/create_game_view.dart | 139 ++++++++++++++++++ .../widgets/text_input/text_input_field.dart | 13 ++ .../tiles/title_description_list_tile.dart | 12 +- pubspec.yaml | 2 +- 13 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 lib/data/dto/game.dart create mode 100644 lib/presentation/views/main_menu/match_view/create_match/game_view/choose_ruleset_view.dart create mode 100644 lib/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart 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/dto/game.dart b/lib/data/dto/game.dart new file mode 100644 index 0000000..2bed4d0 --- /dev/null +++ b/lib/data/dto/game.dart @@ -0,0 +1,23 @@ +import 'package:clock/clock.dart'; +import 'package:uuid/uuid.dart'; + +class Game { + final String id; + final DateTime createdAt; + final String name; + final String? ruleset; + final String? description; + final int? color; + final String? icon; + + Game({ + String? id, + DateTime? createdAt, + required this.name, + this.ruleset, + this.description, + this.color, + this.icon, + }) : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? clock.now(); +} diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 2ef9ee9..9b31e35 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -10,6 +10,7 @@ "choose_group": "Gruppe wählen", "choose_ruleset": "Regelwerk wählen", "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,7 +23,10 @@ "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", + "description": "Beschreibung", + "edit_game": "Spielvorlage bearbeiten", "edit_group": "Gruppe bearbeiten", "error_creating_group": "Fehler beim Erstellen 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 fa4adc8..ab2ba30 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -30,6 +30,9 @@ "@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,9 +74,18 @@ "@delete_all_data": { "description": "Confirmation dialog for deleting all data" }, + "@delete_game": { + "description": "Button text to delete a game" + }, "@delete_group": { "description": "Button text to delete a group" }, + "description": { + "description": "Description label" + }, + "edit_game": { + "description": "Button text to edit a game" + }, "@edit_group": { "description": "Button text to edit a group" }, @@ -308,6 +320,7 @@ "choose_group": "Choose Group", "choose_ruleset": "Choose Ruleset", "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", @@ -320,7 +333,10 @@ "days_ago": "{count} days ago", "delete": "Delete", "delete_all_data": "Delete all data", + "delete_game": "Delete Game", "delete_group": "Delete Group", + "description": "Description", + "edit_game": "Edit Game", "edit_group": "Edit Group", "error_creating_group": "Error while creating 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 57dbdd8..43e7a01 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -98,6 +98,18 @@ abstract class AppLocalizations { Locale('en'), ]; + /// No description provided for @description. + /// + /// In en, this message translates to: + /// **'Description'** + String get description; + + /// No description provided for @edit_game. + /// + /// In en, this message translates to: + /// **'Edit Game'** + String get edit_game; + /// Label for all players list /// /// In en, this message translates to: @@ -158,6 +170,12 @@ abstract class AppLocalizations { /// **'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 +248,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; + /// Button text to delete a group /// /// 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 f78f9f4..9030c0a 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -8,6 +8,12 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get description => 'Beschreibung'; + + @override + String get edit_game => 'Spielvorlage bearbeiten'; + @override String get all_players => 'Alle Spieler:innen'; @@ -40,6 +46,9 @@ class AppLocalizationsDe extends AppLocalizations { return 'Spieler:in $playerName konnte nicht hinzugefügt werden'; } + @override + String get create_game => 'Spielvorlage erstellen'; + @override String get create_group => 'Gruppe erstellen'; @@ -78,6 +87,9 @@ 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'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 32512c7..669bd15 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -8,6 +8,12 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get description => 'Description'; + + @override + String get edit_game => 'Edit Game'; + @override String get all_players => 'All players'; @@ -40,6 +46,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not add player'; } + @override + String get create_game => 'Create Game'; + @override String get create_group => 'Create Group'; @@ -78,6 +87,9 @@ 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'; 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 32868e4..4d14011 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,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:game_tracker/core/adaptive_page_route.dart'; import 'package:game_tracker/core/custom_theme.dart'; import 'package:game_tracker/core/enums.dart'; +import 'package:game_tracker/data/dto/game.dart'; import 'package:game_tracker/l10n/generated/app_localizations.dart'; +import 'package:game_tracker/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart'; import 'package:game_tracker/presentation/widgets/text_input/custom_search_bar.dart'; import 'package:game_tracker/presentation/widgets/tiles/title_description_list_tile.dart'; @@ -50,6 +53,17 @@ class _ChooseGameViewState extends State { Navigator.of(context).pop(selectedGameIndex); }, ), + actions: [IconButton( + icon: const Icon(Icons.add), + onPressed: () async { + await Navigator.push(context, adaptivePageRoute( + builder: (context) => CreateGameView( + callback: () {}, //TODO: implement callback + ), + ) + ); + }, + )], title: Text(loc.choose_game), ), body: PopScope( @@ -84,7 +98,7 @@ class _ChooseGameViewState extends State { context, ), isHighlighted: selectedGameIndex == index, - onPressed: () async { + onTap: () async { setState(() { if (selectedGameIndex == index) { selectedGameIndex = -1; @@ -93,6 +107,16 @@ class _ChooseGameViewState extends State { } }); }, + onLongPress: () async { + await Navigator.push(context, adaptivePageRoute( + builder: (context) => CreateGameView( + //TODO: implement callback & giving real game to create game view + gameToEdit: Game(name: 'Cabo', description: '', ruleset: 'Highest Points'), + callback: () {}, + ), + ) + ); + }, ); }, ), diff --git a/lib/presentation/views/main_menu/match_view/create_match/game_view/choose_ruleset_view.dart b/lib/presentation/views/main_menu/match_view/create_match/game_view/choose_ruleset_view.dart new file mode 100644 index 0000000..d534173 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/game_view/choose_ruleset_view.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.dart'; +import 'package:game_tracker/l10n/generated/app_localizations.dart'; +import 'package:game_tracker/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, + ); + }, + ), + ), + ), + ); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..a541f2b --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/game_view/create_game_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/adaptive_page_route.dart'; +import 'package:game_tracker/core/constants.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/l10n/generated/app_localizations.dart'; +import 'package:game_tracker/presentation/views/main_menu/match_view/create_match/game_view/choose_ruleset_view.dart'; +import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:game_tracker/presentation/widgets/text_input/text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/tiles/choose_tile.dart'; + +class CreateGameView extends StatefulWidget { + const CreateGameView({super.key, this.gameToEdit, required this.callback}); + + final Game? gameToEdit; + + final VoidCallback callback; + + @override + State createState() => _CreateGameViewState(); +} + +class _CreateGameViewState extends State { + Ruleset? selectedRuleset; + int selectedRulesetIndex = -1; + late List<(Ruleset, String)> _rulesets; + + final _gameNameController = TextEditingController(); + final _descriptionController = TextEditingController(); + + @override + void initState() { + super.initState(); + _gameNameController.addListener(() => setState(() {})); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final loc = AppLocalizations.of(context); + _rulesets = [ + (Ruleset.singleWinner, loc.ruleset_single_winner), + (Ruleset.singleLoser, loc.ruleset_single_loser), + (Ruleset.mostPoints, loc.ruleset_most_points), + (Ruleset.leastPoints, loc.ruleset_least_points), + ]; + + if (widget.gameToEdit != null) { + _gameNameController.text = widget.gameToEdit!.name; + _descriptionController.text = widget.gameToEdit!.description ?? ''; + // TODO: Handle ruleset initialization from gameToEdit + } + } + + @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), + ), + 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); + }); + } + }, + ), + Container( + margin: CustomTheme.tileMargin, + child: TextInputField( + controller: _descriptionController, + hintText: loc.description, + minLines: 6, + maxLines: 6, + maxLength: Constants.MAX_GAME_DESCRIPTION_LENGTH, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(12.0), + child: CustomWidthButton( + text: isEditing ? loc.edit_group : loc.create_game, + sizeRelativeToWidth: 1, + buttonType: ButtonType.primary, + onPressed: _gameNameController.text.trim().isNotEmpty && selectedRulesetIndex != -1 + ? () { + //TODO: Handle saving to db & updating game selection view + Navigator.of(context).pop(); + } + : null, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/text_input/text_input_field.dart b/lib/presentation/widgets/text_input/text_input_field.dart index 8f7a597..f987ac6 100644 --- a/lib/presentation/widgets/text_input/text_input_field.dart +++ b/lib/presentation/widgets/text_input/text_input_field.dart @@ -7,12 +7,16 @@ 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. const TextInputField({ super.key, required this.controller, required this.hintText, this.onChanged, this.maxLength, + this.maxLines = 1, + this.minLines = 1 }); /// The controller for the text input field. @@ -27,12 +31,21 @@ 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; + + @override Widget build(BuildContext context) { return TextField( controller: controller, onChanged: onChanged, maxLength: maxLength, + maxLines: maxLines, + minLines: minLines, decoration: InputDecoration( filled: true, fillColor: CustomTheme.boxColor, diff --git a/lib/presentation/widgets/tiles/title_description_list_tile.dart b/lib/presentation/widgets/tiles/title_description_list_tile.dart index a963d16..21fa04d 100644 --- a/lib/presentation/widgets/tiles/title_description_list_tile.dart +++ b/lib/presentation/widgets/tiles/title_description_list_tile.dart @@ -6,6 +6,7 @@ class TitleDescriptionListTile extends StatelessWidget { /// - [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. + /// - [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), diff --git a/pubspec.yaml b/pubspec.yaml index 33091e5..bb70ee2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: game_tracker description: "Game Tracking App for Card Games" publish_to: 'none' -version: 0.0.9+236 +version: 0.0.9+247 environment: sdk: ^3.8.1