From b102ec4c1c12df9431e4b6d680e9f52f694cdab8 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sun, 23 Nov 2025 22:36:35 +0100 Subject: [PATCH 01/33] Implemented first structure of CreateGameView --- .../create_game/create_game_view.dart | 187 ++++++++++++++++++ .../views/main_menu/game_history_view.dart | 26 ++- 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 lib/presentation/views/main_menu/create_game/create_game_view.dart 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..94a08d5 --- /dev/null +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -0,0 +1,187 @@ +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/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/text_input_field.dart'; +import 'package:provider/provider.dart'; + +class CreateGameView extends StatefulWidget { + const CreateGameView({super.key}); + + @override + State createState() => _CreateGameViewState(); +} + +class _CreateGameViewState extends State { + final TextEditingController _gameNameController = TextEditingController(); + late final AppDatabase db; + late Future> _allGroupsFuture; + + late final List groupsList; + + Group? selectedGroup; + Ruleset? selectedRuleset; + + bool isLoading = true; + + late final List skeletonData = List.filled( + 7, + Player(name: 'Player 0'), + ); + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + + _gameNameController.addListener(() { + setState(() {}); + }); + + _allGroupsFuture = db.groupDao.getAllGroups(); + + Future.wait([_allGroupsFuture]).then((result) async { + await Future.delayed(const Duration(milliseconds: 1000)); + groupsList = result[0]; + + if (mounted) { + setState(() { + isLoading = false; + }); + } + }); + } + + @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(() {}); + }, + ), + ), + GestureDetector( + onTap: () async { + selectedRuleset = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ChooseRulesetView(), + ), + ); + setState(() {}); + }, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 15, + ), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Text( + 'Ruleset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text(selectedRuleset == null ? 'None' : 'Single Winner'), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ), + GestureDetector( + onTap: () async { + selectedGroup = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseGroupView(groups: groupsList), + ), + ); + setState(() {}); + }, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 15, + ), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Text( + 'Group', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text(selectedGroup == null ? 'None' : selectedGroup!.name), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ), + const Spacer(), + CustomWidthButton( + text: 'Create game', + sizeRelativeToWidth: 0.95, + buttonType: ButtonType.primary, + onPressed: + (_gameNameController.text.isEmpty || + selectedGroup == null || + selectedRuleset == null) + ? null + : () async { + print('Create game pressed'); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/views/main_menu/game_history_view.dart b/lib/presentation/views/main_menu/game_history_view.dart index a962c05..d28943f 100644 --- a/lib/presentation/views/main_menu/game_history_view.dart +++ b/lib/presentation/views/main_menu/game_history_view.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/presentation/views/main_menu/create_game/create_game_view.dart'; +import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; import 'package:game_tracker/presentation/widgets/tiles/double_row_info_tile.dart'; import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; @@ -141,7 +143,12 @@ class _GameHistoryViewState extends State { ], ), Container( - margin: const EdgeInsets.only(top: 10, bottom: 10, left: 10, right: 10), + margin: const EdgeInsets.only( + top: 10, + bottom: 10, + left: 10, + right: 10, + ), child: SearchBar( leading: const Icon(Icons.search), onChanged: (value) { @@ -170,6 +177,23 @@ class _GameHistoryViewState extends State { }, ), ), + Positioned( + bottom: 110, + width: MediaQuery.of(context).size.width, + child: Center( + child: CustomWidthButton( + text: 'Create Game', + sizeRelativeToWidth: 0.95, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CreateGameView(), + ), + ); + }, + ), + ), + ), ], ), ); From e71cb11295645eaaa9914438fdf370073611bb99 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sun, 23 Nov 2025 22:36:45 +0100 Subject: [PATCH 02/33] Implemented View for choosing group and ruleset --- lib/core/enums.dart | 7 ++ .../create_game/choose_group_view.dart | 41 ++++++++ .../create_game/choose_ruleset_view.dart | 95 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 lib/presentation/views/main_menu/create_game/choose_group_view.dart create mode 100644 lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart 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/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..168fb99 --- /dev/null +++ b/lib/presentation/views/main_menu/create_game/choose_group_view.dart @@ -0,0 +1,41 @@ +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; + + const ChooseGroupView({super.key, required this.groups}); + + @override + State createState() => _ChooseGroupViewState(); +} + +class _ChooseGroupViewState extends State { + @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: () => Navigator.of(context).pop(widget.groups[index]), + child: GroupTile(group: widget.groups[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..5939405 --- /dev/null +++ b/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.dart'; + +class ChooseRulesetView extends StatefulWidget { + const ChooseRulesetView({super.key}); + + @override + State createState() => _ChooseRulesetViewState(); +} + +class _ChooseRulesetViewState extends State { + 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 + 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: rulesets.length, + itemBuilder: (BuildContext context, int index) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(rulesets[index].$1), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + rulesets[index].$2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + rulesets[index].$3, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 2.5), + ], + ), + ), + ); + }, + ), + ); + } +} From 72e48ada94118e6133d450732ded256f26fb5f7a Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 13:31:42 +0100 Subject: [PATCH 03/33] Added seletion highlighting for selected group --- .../create_game/choose_group_view.dart | 30 +++++++++++++++++-- .../create_game/create_game_view.dart | 17 +++++++++-- .../widgets/tiles/group_tile.dart | 25 +++++++++++----- 3 files changed, 60 insertions(+), 12 deletions(-) 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 index 168fb99..4eb6eab 100644 --- a/lib/presentation/views/main_menu/create_game/choose_group_view.dart +++ b/lib/presentation/views/main_menu/create_game/choose_group_view.dart @@ -5,14 +5,27 @@ import 'package:game_tracker/presentation/widgets/tiles/group_tile.dart'; class ChooseGroupView extends StatefulWidget { final List groups; + final int? selectedGroupIndex; - const ChooseGroupView({super.key, required this.groups}); + const ChooseGroupView({ + super.key, + required this.groups, + this.selectedGroupIndex, + }); @override State createState() => _ChooseGroupViewState(); } class _ChooseGroupViewState extends State { + late int selectedGroup; + + @override + void initState() { + selectedGroup = widget.selectedGroupIndex ?? -1; + super.initState(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -31,8 +44,19 @@ class _ChooseGroupViewState extends State { itemCount: widget.groups.length, itemBuilder: (BuildContext context, int index) { return GestureDetector( - onTap: () => Navigator.of(context).pop(widget.groups[index]), - child: GroupTile(group: widget.groups[index]), + onTap: () { + setState(() { + selectedGroup = index; + }); + + Future.delayed(const Duration(milliseconds: 500), () { + Navigator.of(context).pop(widget.groups[index]); + }); + }, + child: GroupTile( + group: widget.groups[index], + isHighlighted: selectedGroup == index, + ), ); }, ), 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 index 94a08d5..4c776f9 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -2,6 +2,7 @@ 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'; @@ -25,6 +26,7 @@ class _CreateGameViewState extends State { late final List groupsList; Group? selectedGroup; + int selectedGroupIndex = -1; Ruleset? selectedRuleset; bool isLoading = true; @@ -128,9 +130,15 @@ class _CreateGameViewState extends State { onTap: () async { selectedGroup = await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => ChooseGroupView(groups: groupsList), + builder: (context) => ChooseGroupView( + groups: groupsList, + selectedGroupIndex: selectedGroupIndex, + ), ), ); + selectedGroupIndex = groupsList.indexWhere( + (g) => g.id == selectedGroup?.id, + ); setState(() {}); }, child: Container( @@ -175,7 +183,12 @@ class _CreateGameViewState extends State { selectedRuleset == null) ? null : () async { - print('Create game pressed'); + Game game = Game( + name: _gameNameController.text.trim(), + createdAt: DateTime.now(), + group: selectedGroup!, + ); + print('Creating game: ${game.name}'); }, ), const SizedBox(height: 20), diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index fa91477..8627b0e 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -4,20 +4,31 @@ 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 + ? BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow(color: Colors.blue.withAlpha(120), blurRadius: 12), + ], + ) + : BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + duration: const Duration(milliseconds: 150), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ From c284d10943c44a7185d1fc970c4711494cf25254 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 13:49:25 +0100 Subject: [PATCH 04/33] Refactoring --- .../main_menu/create_game/choose_group_view.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 index 4eb6eab..c98ce6d 100644 --- a/lib/presentation/views/main_menu/create_game/choose_group_view.dart +++ b/lib/presentation/views/main_menu/create_game/choose_group_view.dart @@ -5,12 +5,12 @@ import 'package:game_tracker/presentation/widgets/tiles/group_tile.dart'; class ChooseGroupView extends StatefulWidget { final List groups; - final int? selectedGroupIndex; + final int initialGroupIndex; const ChooseGroupView({ super.key, required this.groups, - this.selectedGroupIndex, + required this.initialGroupIndex, }); @override @@ -18,11 +18,11 @@ class ChooseGroupView extends StatefulWidget { } class _ChooseGroupViewState extends State { - late int selectedGroup; + late int selectedGroupIndex; @override void initState() { - selectedGroup = widget.selectedGroupIndex ?? -1; + selectedGroupIndex = widget.initialGroupIndex; super.initState(); } @@ -46,16 +46,17 @@ class _ChooseGroupViewState extends State { return GestureDetector( onTap: () { setState(() { - selectedGroup = index; + 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: selectedGroup == index, + isHighlighted: selectedGroupIndex == index, ), ); }, From e182c815a16c398be37fbd832fb9a1c3ab7de11c Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 13:49:53 +0100 Subject: [PATCH 05/33] Implemented ruleset list tile with highlighting --- .../create_game/choose_ruleset_view.dart | 91 ++++++------------- .../create_game/create_game_view.dart | 34 ++++++- .../widgets/tiles/ruleset_list_tile.dart | 56 ++++++++++++ 3 files changed, 118 insertions(+), 63 deletions(-) create mode 100644 lib/presentation/widgets/tiles/ruleset_list_tile.dart 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 index 5939405..0055761 100644 --- a/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart +++ b/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart @@ -1,37 +1,29 @@ 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 { - const ChooseRulesetView({super.key}); + 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 { - 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.', - ), - ]; + late int selectedRulesetIndex; + + @override + void initState() { + selectedRulesetIndex = widget.initialRulesetIndex; + super.initState(); + } @override Widget build(BuildContext context) { @@ -48,45 +40,22 @@ class _ChooseRulesetViewState extends State { ), body: ListView.builder( padding: const EdgeInsets.only(bottom: 85), - itemCount: rulesets.length, + itemCount: widget.rulesets.length, itemBuilder: (BuildContext context, int index) { - return GestureDetector( - onTap: () => Navigator.of(context).pop(rulesets[index].$1), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - rulesets[index].$2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - ], - ), - const SizedBox(height: 5), - Text( - rulesets[index].$3, - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 2.5), - ], - ), - ), + 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, ); }, ), 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 index 4c776f9..2626961 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -28,9 +28,33 @@ class _CreateGameViewState extends State { Group? selectedGroup; int selectedGroupIndex = -1; Ruleset? selectedRuleset; + int selectedRulesetIndex = -1; bool isLoading = true; + 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.', + ), + ]; + late final List skeletonData = List.filled( 7, Player(name: 'Player 0'), @@ -90,9 +114,15 @@ class _CreateGameViewState extends State { onTap: () async { selectedRuleset = await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => const ChooseRulesetView(), + builder: (context) => ChooseRulesetView( + rulesets: rulesets, + initialRulesetIndex: selectedRulesetIndex, + ), ), ); + selectedRulesetIndex = rulesets.indexWhere( + (r) => r.$1 == selectedRuleset, + ); setState(() {}); }, child: Container( @@ -132,7 +162,7 @@ class _CreateGameViewState extends State { MaterialPageRoute( builder: (context) => ChooseGroupView( groups: groupsList, - selectedGroupIndex: selectedGroupIndex, + initialGroupIndex: selectedGroupIndex, ), ), ); 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..0cc6071 --- /dev/null +++ b/lib/presentation/widgets/tiles/ruleset_list_tile.dart @@ -0,0 +1,56 @@ +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) { + // Use the callback directly so a null onPressed disables taps + 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), + ], + ), + ), + ); + } +} From 9054b163cec5f22b7d988757fa44a6bfa7ac6301 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 13:50:23 +0100 Subject: [PATCH 06/33] Added BoxDecorations to Custom Theme --- lib/core/custom_theme.dart | 13 +++++++++++++ lib/presentation/widgets/tiles/group_tile.dart | 15 ++------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index 16e9585..def7379 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: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ); + + static BoxDecoration highlightedBoxDecoration = BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.blue.withAlpha(120), blurRadius: 12)], + ); + static AppBarTheme appBarTheme = AppBarTheme( backgroundColor: backgroundColor, foregroundColor: Colors.white, diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index 8627b0e..248c1c6 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -15,19 +15,8 @@ class GroupTile extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), decoration: isHighlighted - ? BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: Colors.blue), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow(color: Colors.blue.withAlpha(120), blurRadius: 12), - ], - ) - : BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - borderRadius: BorderRadius.circular(12), - ), + ? CustomTheme.highlightedBoxDecoration + : CustomTheme.standardBoxDecoration, duration: const Duration(milliseconds: 150), child: Column( crossAxisAlignment: CrossAxisAlignment.start, From 2ba710ca2d781ee0e07d7b708528f14279e7d9af Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 13:53:28 +0100 Subject: [PATCH 07/33] Replaced unique box decorations with standardBoxDecoration --- lib/core/custom_theme.dart | 6 +++--- .../main_menu/create_game/create_game_view.dart | 12 ++---------- .../views/main_menu/create_group_view.dart | 6 +----- lib/presentation/widgets/tiles/info_tile.dart | 6 +----- lib/presentation/widgets/tiles/quick_info_tile.dart | 6 +----- .../widgets/tiles/settings_list_tile.dart | 6 +----- .../widgets/tiles/text_icon_list_tile.dart | 6 +----- 7 files changed, 10 insertions(+), 38 deletions(-) diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index def7379..b80b4f6 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -9,13 +9,13 @@ class CustomTheme { static Color boxBorder = const Color(0xFF272727); static BoxDecoration standardBoxDecoration = BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), + color: boxColor, + border: Border.all(color: boxBorder), borderRadius: BorderRadius.circular(12), ); static BoxDecoration highlightedBoxDecoration = BoxDecoration( - color: CustomTheme.boxColor, + color: boxColor, border: Border.all(color: Colors.blue), borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.blue.withAlpha(120), blurRadius: 12)], 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 index 2626961..736c86d 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -134,11 +134,7 @@ class _CreateGameViewState extends State { vertical: 10, horizontal: 15, ), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - borderRadius: BorderRadius.circular(12), - ), + decoration: CustomTheme.standardBoxDecoration, child: Row( children: [ const Text( @@ -180,11 +176,7 @@ class _CreateGameViewState extends State { vertical: 10, horizontal: 15, ), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorder), - borderRadius: BorderRadius.circular(12), - ), + decoration: CustomTheme.standardBoxDecoration, child: Row( children: [ const Text( diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart index 59f72ed..72724e9 100644 --- a/lib/presentation/views/main_menu/create_group_view.dart +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -105,11 +105,7 @@ class _CreateGroupViewState extends State { 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/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/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, From 093c527591cde86b5996f6fd7a95f19853337deb Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 15:17:29 +0100 Subject: [PATCH 08/33] Implemented TabView --- .../create_game/choose_ruleset_view.dart | 114 +++++++++++++----- 1 file changed, 85 insertions(+), 29 deletions(-) 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 index 0055761..772cef5 100644 --- a/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart +++ b/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart @@ -27,37 +27,93 @@ class _ChooseRulesetViewState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: CustomTheme.backgroundColor, - appBar: AppBar( + return DefaultTabController( + length: 2, + initialIndex: 0, + child: Scaffold( backgroundColor: CustomTheme.backgroundColor, - scrolledUnderElevation: 0, - title: const Text( - 'Choose Group', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + appBar: AppBar( + backgroundColor: CustomTheme.backgroundColor, + scrolledUnderElevation: 0, + title: const Text( + 'Choose Gametype', + 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), + ), + ), + ], + ), + ), + ], ), - centerTitle: true, - ), - body: 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, - ); - }, ), ); } From 3afae892349f19a3672054ce3da821d74538bc18 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 15:17:46 +0100 Subject: [PATCH 09/33] Added Skeleton Loading --- .../create_game/create_game_view.dart | 278 ++++++++++-------- 1 file changed, 159 insertions(+), 119 deletions(-) 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 index 736c86d..2df52ec 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -4,12 +4,12 @@ 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/text_input_field.dart'; import 'package:provider/provider.dart'; +import 'package:skeletonizer/skeletonizer.dart'; class CreateGameView extends StatefulWidget { const CreateGameView({super.key}); @@ -55,24 +55,15 @@ class _CreateGameViewState extends State { ), ]; - late final List skeletonData = List.filled( - 7, - Player(name: 'Player 0'), - ); - @override void initState() { super.initState(); db = Provider.of(context, listen: false); - _gameNameController.addListener(() { - setState(() {}); - }); - _allGroupsFuture = db.groupDao.getAllGroups(); Future.wait([_allGroupsFuture]).then((result) async { - await Future.delayed(const Duration(milliseconds: 1000)); + await Future.delayed(const Duration(milliseconds: 250)); groupsList = result[0]; if (mounted) { @@ -97,126 +88,175 @@ class _CreateGameViewState extends State { 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) { + child: Skeletonizer( + effect: PulseEffect( + from: Colors.grey[800]!, + to: Colors.grey[600]!, + duration: const Duration(milliseconds: 800), + ), + enabled: isLoading, + enableSwitchAnimation: true, + switchAnimationConfig: const SwitchAnimationConfig( + duration: Duration(milliseconds: 200), + switchInCurve: Curves.linear, + switchOutCurve: Curves.linear, + transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, + layoutBuilder: AnimatedSwitcher.defaultLayoutBuilder, + ), + 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(() {}); + }, + ), + ), + GestureDetector( + onTap: () async { + selectedRuleset = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseRulesetView( + rulesets: rulesets, + initialRulesetIndex: selectedRulesetIndex, + ), + ), + ); + selectedRulesetIndex = rulesets.indexWhere( + (r) => r.$1 == selectedRuleset, + ); setState(() {}); }, - ), - ), - GestureDetector( - onTap: () async { - selectedRuleset = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChooseRulesetView( - rulesets: rulesets, - initialRulesetIndex: selectedRulesetIndex, - ), + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 5, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 15, + ), + decoration: CustomTheme.standardBoxDecoration, + child: Row( + children: [ + const Text( + 'Ruleset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + selectedRuleset == null + ? 'None' + : translateRulesetToString(selectedRuleset!), + ), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], ), - ); - selectedRulesetIndex = rulesets.indexWhere( - (r) => r.$1 == selectedRuleset, - ); - setState(() {}); - }, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - decoration: CustomTheme.standardBoxDecoration, - child: Row( - children: [ - const Text( - 'Ruleset', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + ), + GestureDetector( + onTap: () async { + selectedGroup = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseGroupView( + groups: groupsList, + initialGroupIndex: selectedGroupIndex, ), ), - const Spacer(), - Text(selectedRuleset == null ? 'None' : 'Single Winner'), - const SizedBox(width: 10), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), - ), - GestureDetector( - onTap: () async { - selectedGroup = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChooseGroupView( - groups: groupsList, - initialGroupIndex: selectedGroupIndex, - ), + ); + selectedGroupIndex = groupsList.indexWhere( + (g) => g.id == selectedGroup?.id, + ); + setState(() {}); + }, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 5, ), - ); - selectedGroupIndex = groupsList.indexWhere( - (g) => g.id == selectedGroup?.id, - ); - setState(() {}); - }, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - decoration: CustomTheme.standardBoxDecoration, - child: Row( - children: [ - const Text( - 'Group', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 15, + ), + decoration: CustomTheme.standardBoxDecoration, + child: Row( + children: [ + const Text( + 'Group', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - const Spacer(), - Text(selectedGroup == null ? 'None' : selectedGroup!.name), - const SizedBox(width: 10), - const Icon(Icons.arrow_forward_ios, size: 16), - ], + const Spacer(), + Text( + selectedGroup == null ? 'None' : selectedGroup!.name, + ), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), ), ), - ), - const Spacer(), - CustomWidthButton( - text: 'Create game', - sizeRelativeToWidth: 0.95, - buttonType: ButtonType.primary, - onPressed: - (_gameNameController.text.isEmpty || - selectedGroup == null || - selectedRuleset == null) - ? null - : () async { - Game game = Game( - name: _gameNameController.text.trim(), - createdAt: DateTime.now(), - group: selectedGroup!, - ); - print('Creating game: ${game.name}'); - }, - ), - const SizedBox(height: 20), - ], + Container( + decoration: CustomTheme.standardBoxDecoration, + width: MediaQuery.of(context).size.width * 0.95, + height: 400, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.symmetric(vertical: 10), + child: const Center(child: Text('PlayerComponent')), + ), + const Spacer(), + CustomWidthButton( + text: 'Create game', + sizeRelativeToWidth: 0.95, + buttonType: ButtonType.primary, + onPressed: + (_gameNameController.text.isEmpty || + selectedGroup == null || + selectedRuleset == null) + ? null + : () async { + Game game = Game( + name: _gameNameController.text.trim(), + createdAt: DateTime.now(), + group: selectedGroup!, + ); + // TODO: Replace with navigation to GameResultView() + print('Created game: $game'); + Navigator.pop(context); + }, + ), + 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'; + } + } } From 1faa74f02623f71830b2568df92ce029a00a7b46 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 15:17:55 +0100 Subject: [PATCH 10/33] Removed comment --- lib/presentation/widgets/tiles/ruleset_list_tile.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/presentation/widgets/tiles/ruleset_list_tile.dart b/lib/presentation/widgets/tiles/ruleset_list_tile.dart index 0cc6071..13eaf82 100644 --- a/lib/presentation/widgets/tiles/ruleset_list_tile.dart +++ b/lib/presentation/widgets/tiles/ruleset_list_tile.dart @@ -17,7 +17,6 @@ class RulesetListTile extends StatelessWidget { @override Widget build(BuildContext context) { - // Use the callback directly so a null onPressed disables taps return GestureDetector( onTap: onPressed, child: AnimatedContainer( From 7c7676abee82968435f9b0e4978e317d32cb0a51 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 16:17:15 +0100 Subject: [PATCH 11/33] Implemented CustomTextInputField --- .../create_game/create_game_view.dart | 34 +++++++++++-------- .../text_input/custom_text_input_field.dart | 32 +++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 lib/presentation/widgets/text_input/custom_text_input_field.dart 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 index 2df52ec..ef7e267 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -7,7 +7,7 @@ import 'package:game_tracker/data/dto/group.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/text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/text_input/custom_text_input_field.dart'; import 'package:provider/provider.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -19,19 +19,31 @@ class CreateGameView extends StatefulWidget { } class _CreateGameViewState extends State { - final TextEditingController _gameNameController = TextEditingController(); late final AppDatabase db; late Future> _allGroupsFuture; + final TextEditingController _gameNameController = TextEditingController(); + /// List of all groups from the database late final List groupsList; + /// 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; bool isLoading = true; + /// List of available rulesets with their display names and descriptions + /// as tuples of (Ruleset, String, String) List<(Ruleset, String, String)> rulesets = [ ( Ruleset.singleWinner, @@ -106,18 +118,12 @@ class _CreateGameViewState extends State { 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(() {}); - }, - ), + CustomTextInputField( + controller: _gameNameController, + hintText: 'Game name', + onChanged: (value) { + setState(() {}); + }, ), GestureDetector( onTap: () async { diff --git a/lib/presentation/widgets/text_input/custom_text_input_field.dart b/lib/presentation/widgets/text_input/custom_text_input_field.dart new file mode 100644 index 0000000..2b6efd5 --- /dev/null +++ b/lib/presentation/widgets/text_input/custom_text_input_field.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/presentation/widgets/text_input_field.dart'; + +class CustomTextInputField extends StatefulWidget { + final TextEditingController controller; + final String hintText; + final void Function(String)? onChanged; + + const CustomTextInputField({ + super.key, + required this.controller, + required this.hintText, + this.onChanged, + }); + + @override + State createState() => _CustomTextInputFieldState(); +} + +class _CustomTextInputFieldState extends State { + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: TextInputField( + controller: widget.controller, + hintText: widget.hintText, + onChanged: widget.onChanged, + ), + ); + } +} From 9efbc129092aed6d559c5da9ee66b314bbe3a7d1 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 16:18:11 +0100 Subject: [PATCH 12/33] moved input widgets to new folder --- .../create_game/create_game_view.dart | 21 ++++++++---- .../views/main_menu/create_group_view.dart | 4 +-- .../{ => text_input}/custom_search_bar.dart | 0 .../text_input/custom_text_input_field.dart | 32 ------------------- .../{ => text_input}/text_input_field.dart | 0 5 files changed, 16 insertions(+), 41 deletions(-) rename lib/presentation/widgets/{ => text_input}/custom_search_bar.dart (100%) delete mode 100644 lib/presentation/widgets/text_input/custom_text_input_field.dart rename lib/presentation/widgets/{ => text_input}/text_input_field.dart (100%) 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 index ef7e267..82f44a9 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -7,7 +7,7 @@ import 'package:game_tracker/data/dto/group.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/text_input/custom_text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/text_input/text_input_field.dart'; import 'package:provider/provider.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -118,13 +118,20 @@ class _CreateGameViewState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - CustomTextInputField( - controller: _gameNameController, - hintText: 'Game name', - onChanged: (value) { - setState(() {}); - }, + Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: TextInputField( + controller: _gameNameController, + hintText: 'Game name', + onChanged: (value) { + setState(() {}); + }, + ), ), + GestureDetector( onTap: () async { selectedRuleset = await Navigator.of(context).push( diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart index 72724e9..fcb914e 100644 --- a/lib/presentation/views/main_menu/create_group_view.dart +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -5,8 +5,8 @@ import 'package:game_tracker/data/db/database.dart'; 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/custom_search_bar.dart'; -import 'package:game_tracker/presentation/widgets/text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/text_input/custom_search_bar.dart'; +import 'package:game_tracker/presentation/widgets/text_input/text_input_field.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'; 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/custom_text_input_field.dart b/lib/presentation/widgets/text_input/custom_text_input_field.dart deleted file mode 100644 index 2b6efd5..0000000 --- a/lib/presentation/widgets/text_input/custom_text_input_field.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:game_tracker/presentation/widgets/text_input_field.dart'; - -class CustomTextInputField extends StatefulWidget { - final TextEditingController controller; - final String hintText; - final void Function(String)? onChanged; - - const CustomTextInputField({ - super.key, - required this.controller, - required this.hintText, - this.onChanged, - }); - - @override - State createState() => _CustomTextInputFieldState(); -} - -class _CustomTextInputFieldState extends State { - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: TextInputField( - controller: widget.controller, - hintText: widget.hintText, - onChanged: widget.onChanged, - ), - ); - } -} 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 From 7cfffadb863a0032c9c5b95c2fb4882b335c598c Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 22:20:44 +0100 Subject: [PATCH 13/33] Corrected import --- lib/presentation/widgets/player_selection.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index ad15363..592226c 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'; @@ -58,11 +58,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: [ From e489d16c5109c192600051b9fb643ed8c735bd21 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 22:20:53 +0100 Subject: [PATCH 14/33] Removed imports --- lib/presentation/views/main_menu/create_group_view.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart index 6f70d7c..cbaee6d 100644 --- a/lib/presentation/views/main_menu/create_group_view.dart +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -6,12 +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/custom_search_bar.dart'; import 'package:game_tracker/presentation/widgets/text_input/text_input_field.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'; import 'package:provider/provider.dart'; class CreateGroupView extends StatefulWidget { From 5fbf2ccb45da517435b402027e6b4376cb974e10 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Mon, 24 Nov 2025 22:21:27 +0100 Subject: [PATCH 15/33] Implemented app skeleton --- .../main_menu/create_game/create_game_view.dart | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) 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 index 82f44a9..4c892b9 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -6,10 +6,10 @@ import 'package:game_tracker/data/dto/game.dart'; import 'package:game_tracker/data/dto/group.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/app_skeleton.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:provider/provider.dart'; -import 'package:skeletonizer/skeletonizer.dart'; class CreateGameView extends StatefulWidget { const CreateGameView({super.key}); @@ -100,21 +100,8 @@ class _CreateGameViewState extends State { centerTitle: true, ), body: SafeArea( - child: Skeletonizer( - effect: PulseEffect( - from: Colors.grey[800]!, - to: Colors.grey[600]!, - duration: const Duration(milliseconds: 800), - ), + child: AppSkeleton( enabled: isLoading, - enableSwitchAnimation: true, - switchAnimationConfig: const SwitchAnimationConfig( - duration: Duration(milliseconds: 200), - switchInCurve: Curves.linear, - switchOutCurve: Curves.linear, - transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, - layoutBuilder: AnimatedSwitcher.defaultLayoutBuilder, - ), child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ From 2838376434886f574c5e8c3ca874c4ccd84f64c7 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Tue, 25 Nov 2025 22:38:54 +0100 Subject: [PATCH 16/33] Implemented player selection --- .../create_game/create_game_view.dart | 305 +++++++++--------- .../widgets/player_selection.dart | 18 +- 2 files changed, 170 insertions(+), 153 deletions(-) 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 index 4c892b9..5f3b97b 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -4,10 +4,11 @@ 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/app_skeleton.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:provider/provider.dart'; @@ -19,12 +20,23 @@ class CreateGameView extends StatefulWidget { } 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 - late final List groupsList; + List groupsList = []; + + /// List of all players from the database + List playerList = []; /// The currently selected group Group? selectedGroup; @@ -40,8 +52,6 @@ class _CreateGameViewState extends State { /// the [ChooseRulesetView] int selectedRulesetIndex = -1; - bool isLoading = true; - /// List of available rulesets with their display names and descriptions /// as tuples of (Ruleset, String, String) List<(Ruleset, String, String)> rulesets = [ @@ -73,16 +83,11 @@ class _CreateGameViewState extends State { db = Provider.of(context, listen: false); _allGroupsFuture = db.groupDao.getAllGroups(); + _allPlayersFuture = db.playerDao.getAllPlayers(); - Future.wait([_allGroupsFuture]).then((result) async { - await Future.delayed(const Duration(milliseconds: 250)); - groupsList = result[0]; - - if (mounted) { - setState(() { - isLoading = false; - }); - } + Future.wait([_allGroupsFuture, _allPlayersFuture]).then((result) async { + groupsList = result[0] as List; + playerList = result[1] as List; }); } @@ -100,147 +105,149 @@ class _CreateGameViewState extends State { centerTitle: true, ), body: SafeArea( - child: AppSkeleton( - enabled: isLoading, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, + 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(() {}); + }, + ), + ), + GestureDetector( + onTap: () async { + selectedRuleset = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseRulesetView( + rulesets: rulesets, + initialRulesetIndex: selectedRulesetIndex, + ), + ), + ); + selectedRulesetIndex = rulesets.indexWhere( + (r) => r.$1 == selectedRuleset, + ); + setState(() {}); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + padding: const EdgeInsets.symmetric( vertical: 10, + horizontal: 15, ), - child: TextInputField( - controller: _gameNameController, - hintText: 'Game name', - onChanged: (value) { - setState(() {}); - }, - ), - ), - - GestureDetector( - onTap: () async { - selectedRuleset = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChooseRulesetView( - rulesets: rulesets, - initialRulesetIndex: selectedRulesetIndex, - ), - ), - ); - selectedRulesetIndex = rulesets.indexWhere( - (r) => r.$1 == selectedRuleset, - ); - setState(() {}); - }, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 5, - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - decoration: CustomTheme.standardBoxDecoration, - child: Row( - children: [ - const Text( - 'Ruleset', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - Text( - selectedRuleset == null - ? 'None' - : translateRulesetToString(selectedRuleset!), - ), - const SizedBox(width: 10), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), - ), - GestureDetector( - onTap: () async { - selectedGroup = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChooseGroupView( - groups: groupsList, - initialGroupIndex: selectedGroupIndex, - ), - ), - ); - selectedGroupIndex = groupsList.indexWhere( - (g) => g.id == selectedGroup?.id, - ); - setState(() {}); - }, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 5, - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - decoration: CustomTheme.standardBoxDecoration, - child: Row( - children: [ - const Text( - 'Group', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - Text( - selectedGroup == null ? 'None' : selectedGroup!.name, - ), - const SizedBox(width: 10), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), - ), - Container( decoration: CustomTheme.standardBoxDecoration, - width: MediaQuery.of(context).size.width * 0.95, - height: 400, - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.symmetric(vertical: 10), - child: const Center(child: Text('PlayerComponent')), + child: Row( + children: [ + const Text( + 'Ruleset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + selectedRuleset == null + ? 'None' + : translateRulesetToString(selectedRuleset!), + ), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), ), - const Spacer(), - CustomWidthButton( - text: 'Create game', - sizeRelativeToWidth: 0.95, - buttonType: ButtonType.primary, - onPressed: - (_gameNameController.text.isEmpty || - selectedGroup == null || - selectedRuleset == null) - ? null - : () async { - Game game = Game( - name: _gameNameController.text.trim(), - createdAt: DateTime.now(), - group: selectedGroup!, - ); - // TODO: Replace with navigation to GameResultView() - print('Created game: $game'); - Navigator.pop(context); - }, + ), + GestureDetector( + onTap: () async { + selectedGroup = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChooseGroupView( + groups: groupsList, + initialGroupIndex: selectedGroupIndex, + ), + ), + ); + selectedGroupIndex = groupsList.indexWhere( + (g) => g.id == selectedGroup?.id, + ); + print('selectedGroup: $selectedGroup'); + print( + playerList + .where( + (p) => !selectedGroup!.members.any((m) => m.id == p.id), + ) + .toList(), + ); + setState(() {}); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 15, + ), + decoration: CustomTheme.standardBoxDecoration, + child: Row( + children: [ + const Text( + 'Group', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text(selectedGroup == null ? 'None' : selectedGroup!.name), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), ), - const SizedBox(height: 20), - ], - ), + ), + 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) { + print(value); + }, + ), + ), + + CustomWidthButton( + text: 'Create game', + sizeRelativeToWidth: 0.95, + buttonType: ButtonType.primary, + onPressed: + (_gameNameController.text.isEmpty || + selectedGroup == null || + selectedRuleset == null) + ? null + : () async { + Game game = Game( + name: _gameNameController.text.trim(), + createdAt: DateTime.now(), + group: selectedGroup!, + ); + // TODO: Replace with navigation to GameResultView() + print('Created game: $game'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 20), + ], ), ), ); diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index 592226c..092a613 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.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]; + } }); }); } From 733df2dcb595d386f2088d120f7f343fe6a98386 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 12:27:07 +0100 Subject: [PATCH 17/33] Changed highlight color --- lib/core/custom_theme.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index b80b4f6..5930901 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -16,9 +16,9 @@ class CustomTheme { static BoxDecoration highlightedBoxDecoration = BoxDecoration( color: boxColor, - border: Border.all(color: Colors.blue), + border: Border.all(color: primaryColor), borderRadius: BorderRadius.circular(12), - boxShadow: [BoxShadow(color: Colors.blue.withAlpha(120), blurRadius: 12)], + boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)], ); static AppBarTheme appBarTheme = AppBarTheme( From 84338f8f663d3a5ceacbe5c5c2f1986865de5485 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 12:28:11 +0100 Subject: [PATCH 18/33] Changed title --- .../views/main_menu/create_game/choose_ruleset_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 772cef5..b54f56e 100644 --- a/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart +++ b/lib/presentation/views/main_menu/create_game/choose_ruleset_view.dart @@ -36,7 +36,7 @@ class _ChooseRulesetViewState extends State { backgroundColor: CustomTheme.backgroundColor, scrolledUnderElevation: 0, title: const Text( - 'Choose Gametype', + 'Choose Ruleset', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), centerTitle: true, From 27424694ce8f646acc8f050f795bdbd0ed4fbc11 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 12:31:33 +0100 Subject: [PATCH 19/33] Removed unnecessary prints --- .../views/main_menu/create_game/create_game_view.dart | 8 -------- 1 file changed, 8 deletions(-) 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 index 5f3b97b..e17e9b8 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -174,14 +174,6 @@ class _CreateGameViewState extends State { selectedGroupIndex = groupsList.indexWhere( (g) => g.id == selectedGroup?.id, ); - print('selectedGroup: $selectedGroup'); - print( - playerList - .where( - (p) => !selectedGroup!.members.any((m) => m.id == p.id), - ) - .toList(), - ); setState(() {}); }, child: Container( From 919c9f57acfa976e07553d93b10cda8a2f68a12f Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 12:35:34 +0100 Subject: [PATCH 20/33] Fixed button state --- .../create_game/create_game_view.dart | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) 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 index e17e9b8..9b9b4d7 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -52,6 +52,9 @@ class _CreateGameViewState extends State { /// 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 = [ @@ -213,7 +216,9 @@ class _CreateGameViewState extends State { ) .toList(), onChanged: (value) { - print(value); + setState(() { + selectedPlayers = value; + }); }, ), ), @@ -222,21 +227,19 @@ class _CreateGameViewState extends State { text: 'Create game', sizeRelativeToWidth: 0.95, buttonType: ButtonType.primary, - onPressed: - (_gameNameController.text.isEmpty || - selectedGroup == null || - selectedRuleset == null) - ? null - : () async { + 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), ], @@ -258,4 +261,13 @@ class _CreateGameViewState extends State { 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; + } } From b5234c765ce20841d0a6445d1cb443f7ae2bc730 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 12:40:06 +0100 Subject: [PATCH 21/33] Changed create game button size --- lib/presentation/views/main_menu/game_history_view.dart | 4 ++-- lib/presentation/views/main_menu/groups_view.dart | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/presentation/views/main_menu/game_history_view.dart b/lib/presentation/views/main_menu/game_history_view.dart index d28943f..7fbe025 100644 --- a/lib/presentation/views/main_menu/game_history_view.dart +++ b/lib/presentation/views/main_menu/game_history_view.dart @@ -178,12 +178,12 @@ class _GameHistoryViewState extends State { ), ), Positioned( - bottom: 110, + bottom: MediaQuery.paddingOf(context).bottom, width: MediaQuery.of(context).size.width, child: Center( child: CustomWidthButton( text: 'Create Game', - sizeRelativeToWidth: 0.95, + sizeRelativeToWidth: 0.90, onPressed: () { Navigator.of(context).push( MaterialPageRoute( 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( From 745aaef978dcfc99055dc8130290a2effba9cad8 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 13:12:15 +0100 Subject: [PATCH 22/33] Implemented ChooseTile --- .../create_game/create_game_view.dart | 67 ++++--------------- .../widgets/tiles/choose_tile.dart | 43 ++++++++++++ 2 files changed, 56 insertions(+), 54 deletions(-) create mode 100644 lib/presentation/widgets/tiles/choose_tile.dart 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 index 9b9b4d7..485118b 100644 --- a/lib/presentation/views/main_menu/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/create_game/create_game_view.dart @@ -10,6 +10,7 @@ import 'package:game_tracker/presentation/views/main_menu/create_game/choose_rul 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 { @@ -121,8 +122,12 @@ class _CreateGameViewState extends State { }, ), ), - GestureDetector( - onTap: () async { + ChooseTile( + title: 'Ruleset', + trailingText: selectedRuleset == null + ? 'None' + : translateRulesetToString(selectedRuleset!), + onPressed: () async { selectedRuleset = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ChooseRulesetView( @@ -136,36 +141,13 @@ class _CreateGameViewState extends State { ); setState(() {}); }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - decoration: CustomTheme.standardBoxDecoration, - child: Row( - children: [ - const Text( - 'Ruleset', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - Text( - selectedRuleset == null - ? 'None' - : translateRulesetToString(selectedRuleset!), - ), - const SizedBox(width: 10), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), ), - GestureDetector( - onTap: () async { + ChooseTile( + title: 'Group', + trailingText: selectedGroup == null + ? 'None' + : selectedGroup!.name, + onPressed: () async { selectedGroup = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ChooseGroupView( @@ -179,29 +161,6 @@ class _CreateGameViewState extends State { ); setState(() {}); }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - decoration: CustomTheme.standardBoxDecoration, - child: Row( - children: [ - const Text( - 'Group', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - Text(selectedGroup == null ? 'None' : selectedGroup!.name), - const SizedBox(width: 10), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), ), Expanded( child: PlayerSelection( 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), + ], + ), + ), + ); + } +} From 738f242eee50b4a3a35ee25b336f4880fd722b6b Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 13:48:53 +0100 Subject: [PATCH 23/33] Implemented methods and test for winner --- lib/data/dao/game_dao.dart | 45 ++++++++++++++++++++++ test/db_tests/game_test.dart | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index 18792b5..ad6b090 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -253,4 +253,49 @@ 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; + } } diff --git a/test/db_tests/game_test.dart b/test/db_tests/game_test.dart index 4cf6982..ff13892 100644 --- a/test/db_tests/game_test.dart +++ b/test/db_tests/game_test.dart @@ -234,5 +234,79 @@ 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); + }); }); } From 397c5c1550f328924c373b74165c01807ae574bc Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 14:17:11 +0100 Subject: [PATCH 24/33] Added updateGroupOfGame(), added docc & tests --- lib/data/dao/game_dao.dart | 10 +++- lib/data/dao/group_game_dao.dart | 21 +++++++- test/db_tests/group_game_test.dart | 80 +++++++++++++++++++++++------- 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index ad6b090..6d9f316 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -65,6 +65,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Adds a new [Game] to the database. /// Also adds associated players and group if they exist. + /// If a game, player, or group already exists, it will be replaced. Future addGame({required Game game}) async { await db.transaction(() async { await into(gameTable).insert( @@ -89,11 +90,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, + ); } }); } + /// Adds multiple [Game]s to the database in a batch operation. + /// Also adds associated players and groups if they exist. + /// If the [games] list is empty, the method returns immediately. + /// If a game, player, or group already exists, it will be replaced. Future addGames({required List games}) async { if (games.isEmpty) return; await db.transaction(() async { diff --git a/lib/data/dao/group_game_dao.dart b/lib/data/dao/group_game_dao.dart index f3ddcc7..3f7a146 100644 --- a/lib/data/dao/group_game_dao.dart +++ b/lib/data/dao/group_game_dao.dart @@ -11,8 +11,12 @@ class GroupGameDao extends DatabaseAccessor GroupGameDao(super.db); /// Associates a group with a game by inserting a record into the - /// [GroupGameTable]. - Future addGroupToGame(String gameId, String groupId) async { + /// [GroupGameTable]. If there is already group associated to the game, + /// it will be replaced. + Future addGroupToGame({ + required String gameId, + required String groupId, + }) async { await into(groupGameTable).insert( GroupGameTableCompanion.insert(groupId: groupId, gameId: gameId), mode: InsertMode.insertOrReplace, @@ -76,4 +80,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/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); + } } }); }); From 499415e0c5cf5fa0bf9f9eb3a26494b09fec9757 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 14:39:38 +0100 Subject: [PATCH 25/33] Added updatePlayersFromGame(), added docs & tests --- lib/data/dao/player_game_dao.dart | 46 +++++++++++++++++++++++++++++ test/db_tests/player_game_test.dart | 44 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) 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/test/db_tests/player_game_test.dart b/test/db_tests/player_game_test.dart index e8fd707..4c3bc77 100644 --- a/test/db_tests/player_game_test.dart +++ b/test/db_tests/player_game_test.dart @@ -136,5 +136,49 @@ 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.addPlayers(players: newPlayers); + + // First, remove all existing players + final existingPlayers = await database.playerGameDao.getPlayersOfGame( + gameId: testGameOnlyPlayers.id, + ); + + print('existingPlayers: $existingPlayers'); + 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); + } + }); }); } From 2a34243e692910641910dbc22483c12abbc3d310 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 14:42:17 +0100 Subject: [PATCH 26/33] Renamed methods for better distinction --- lib/data/dao/game_dao.dart | 4 ++-- lib/data/dao/group_dao.dart | 2 +- lib/data/dao/player_dao.dart | 2 +- lib/services/data_transfer_service.dart | 6 +++--- test/db_tests/game_test.dart | 2 +- test/db_tests/group_test.dart | 2 +- test/db_tests/player_game_test.dart | 2 +- test/db_tests/player_test.dart | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index 6d9f316..6409ba5 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -79,7 +79,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, @@ -102,7 +102,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Also adds associated players and groups if they exist. /// If the [games] list is empty, the method returns immediately. /// If a game, player, or group already exists, it will be replaced. - Future addGames({required List games}) async { + Future addGamesAsList({required List games}) async { if (games.isEmpty) return; await db.transaction(() async { // Add all games in batch 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/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/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 ff13892..28126cc 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], ); 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 4c3bc77..6fd87c2 100644 --- a/test/db_tests/player_game_test.dart +++ b/test/db_tests/player_game_test.dart @@ -141,7 +141,7 @@ void main() { await database.gameDao.addGame(game: testGameOnlyPlayers); final newPlayers = [testPlayer1, testPlayer2, testPlayer4]; - await database.playerDao.addPlayers(players: newPlayers); + await database.playerDao.addPlayersAsList(players: newPlayers); // First, remove all existing players final existingPlayers = await database.playerGameDao.getPlayersOfGame( 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], ); From dc0e53622134d7a47c66c844701294680d013b77 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Wed, 26 Nov 2025 14:44:41 +0100 Subject: [PATCH 27/33] Implemented updateGameName() and tests for it --- lib/data/dao/game_dao.dart | 13 +++++++++++++ test/db_tests/game_test.dart | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index 6409ba5..daee4b7 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -306,4 +306,17 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { 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/test/db_tests/game_test.dart b/test/db_tests/game_test.dart index 28126cc..4a5cc32 100644 --- a/test/db_tests/game_test.dart +++ b/test/db_tests/game_test.dart @@ -308,5 +308,23 @@ void main() { 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); + }); }); } From 40a3c1b82e27e065ea1170955aa7b4733298357d Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Fri, 28 Nov 2025 13:56:24 +0100 Subject: [PATCH 28/33] Removed comment --- lib/data/dao/group_game_dao.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/data/dao/group_game_dao.dart b/lib/data/dao/group_game_dao.dart index 3f7a146..12bd1ce 100644 --- a/lib/data/dao/group_game_dao.dart +++ b/lib/data/dao/group_game_dao.dart @@ -11,8 +11,7 @@ class GroupGameDao extends DatabaseAccessor GroupGameDao(super.db); /// Associates a group with a game by inserting a record into the - /// [GroupGameTable]. If there is already group associated to the game, - /// it will be replaced. + /// [GroupGameTable]. Future addGroupToGame({ required String gameId, required String groupId, From 126dc7ed9723f8374aede9bd2edae38c768a4c2f Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Fri, 28 Nov 2025 14:00:04 +0100 Subject: [PATCH 29/33] Added exception --- lib/data/dao/group_game_dao.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/data/dao/group_game_dao.dart b/lib/data/dao/group_game_dao.dart index 12bd1ce..da95607 100644 --- a/lib/data/dao/group_game_dao.dart +++ b/lib/data/dao/group_game_dao.dart @@ -16,6 +16,9 @@ class GroupGameDao extends DatabaseAccessor 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, From d2d6852f31b71aa24d1648a430f48f62a77166e9 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Fri, 28 Nov 2025 14:00:26 +0100 Subject: [PATCH 30/33] removed comment --- lib/data/dao/game_dao.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index daee4b7..5d05914 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -65,7 +65,6 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Adds a new [Game] to the database. /// Also adds associated players and group if they exist. - /// If a game, player, or group already exists, it will be replaced. Future addGame({required Game game}) async { await db.transaction(() async { await into(gameTable).insert( From 71b2f30d290b31ad3c78887a3b17921065ed8519 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Fri, 28 Nov 2025 14:00:36 +0100 Subject: [PATCH 31/33] removed comment --- lib/data/dao/game_dao.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index 5d05914..c941499 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -100,7 +100,6 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Adds multiple [Game]s to the database in a batch operation. /// Also adds associated players and groups if they exist. /// If the [games] list is empty, the method returns immediately. - /// If a game, player, or group already exists, it will be replaced. Future addGamesAsList({required List games}) async { if (games.isEmpty) return; await db.transaction(() async { From ec902c6196c283baeb8a18ceaf330c4917688418 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Fri, 5 Dec 2025 18:23:58 +0100 Subject: [PATCH 32/33] Removed print --- test/db_tests/player_game_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/db_tests/player_game_test.dart b/test/db_tests/player_game_test.dart index 6fd87c2..4e2616e 100644 --- a/test/db_tests/player_game_test.dart +++ b/test/db_tests/player_game_test.dart @@ -148,7 +148,6 @@ void main() { gameId: testGameOnlyPlayers.id, ); - print('existingPlayers: $existingPlayers'); if (existingPlayers == null || existingPlayers.isEmpty) { fail('Existing players should not be null or empty'); } From 3169eebd14c0d29d805f31a369aea82031103ef7 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Fri, 5 Dec 2025 18:24:06 +0100 Subject: [PATCH 33/33] Import formatting --- .../views/main_menu/game_history_view.dart | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/lib/presentation/views/main_menu/game_history_view.dart b/lib/presentation/views/main_menu/game_history_view.dart index af18a73..ea5ddd7 100644 --- a/lib/presentation/views/main_menu/game_history_view.dart +++ b/lib/presentation/views/main_menu/game_history_view.dart @@ -5,12 +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/views/main_menu/create_game/create_game_view.dart'; -import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.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 { @@ -40,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')], ), ); @@ -69,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, @@ -137,4 +137,4 @@ class _GameHistoryViewState extends State { ), ); } -} \ No newline at end of file +}