Implementierung der Games #203

Merged
flixcoo merged 34 commits from feature/119-implementierung-der-games-2 into development 2026-05-09 17:13:46 +00:00
15 changed files with 978 additions and 34 deletions
Showing only changes of commit 2f5b9e5ff2 - Show all commits

View File

@@ -1,4 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
@@ -21,6 +21,51 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) {
}
}
/// Translates a [GameColor] enum value to its corresponding localized string.
String translateGameColorToString(GameColor color, BuildContext context) {
final loc = AppLocalizations.of(context);
switch (color) {
case GameColor.red:
return loc.color_red;
case GameColor.blue:
return loc.color_blue;
case GameColor.green:
return loc.color_green;
case GameColor.yellow:
return loc.color_yellow;
case GameColor.purple:
return loc.color_purple;
case GameColor.orange:
return loc.color_orange;
case GameColor.pink:
return loc.color_pink;
case GameColor.teal:
return loc.color_teal;
}
}
/// Returns the [Color] object corresponding to a [GameColor] enum value.
Color getColorFromGameColor(GameColor color) {
switch (color) {
case GameColor.red:
return Colors.red;
case GameColor.blue:
return Colors.blue;
case GameColor.green:
return Colors.green;
case GameColor.yellow:
return Colors.yellow;
case GameColor.purple:
return Colors.purple;
case GameColor.orange:
return Colors.orange;
case GameColor.pink:
return Colors.pink;
case GameColor.teal:
return Colors.teal;
}
}
/// Counts how many players in the match are not part of the group
/// Returns the count as a string, or an empty string if there is no group
String getExtraPlayerCount(Match match) {

View File

@@ -19,4 +19,7 @@ class Constants {
/// Maximum length for team names
static const int MAX_TEAM_NAME_LENGTH = 32;
/// Maximum length for game descriptions
static const int MAX_GAME_DESCRIPTION_LENGTH = 256;
}

View File

@@ -12,16 +12,17 @@ class Game {
final String icon;
Game({
String? id,
DateTime? createdAt,
required this.name,
required this.ruleset,
String? description,
required this.color,
required this.icon,
String? id,
DateTime? createdAt,
String? description,
String? icon,
}) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? clock.now(),
description = description ?? '';
description = description ?? '',
icon = icon ?? '';
@override
String toString() {

View File

@@ -6,10 +6,21 @@
"app_name": "Tallee",
"best_player": "Beste:r Spieler:in",
"cancel": "Abbrechen",
"choose_color": "Farbe wählen",
"choose_game": "Spielvorlage wählen",
"choose_group": "Gruppe wählen",
"choose_ruleset": "Regelwerk wählen",
"color": "Farbe",
"color_blue": "Blau",
"color_green": "Grün",
"color_orange": "Orange",
"color_pink": "Rosa",
"color_purple": "Lila",
"color_red": "Rot",
"color_teal": "Türkis",
"color_yellow": "Gelb",
"could_not_add_player": "Spieler:in {playerName} konnte nicht hinzugefügt werden",
"create_game": "Spielvorlage erstellen",
"create_group": "Gruppe erstellen",
"create_match": "Spiel erstellen",
"create_new_group": "Neue Gruppe erstellen",
@@ -22,13 +33,17 @@
"days_ago": "vor {count} Tagen",
"delete": "Löschen",
"delete_all_data": "Alle Daten löschen",
"delete_game": "Spielvorlage löschen",
"delete_group": "Gruppe löschen",
"delete_match": "Spiel löschen",
"description": "Beschreibung",
"edit_game": "Spielvorlage bearbeiten",
"edit_group": "Gruppe bearbeiten",
"edit_match": "Gruppe bearbeiten",
"enter_points": "Punkte eingeben",
"enter_results": "Ergebnisse eintragen",
sneeex marked this conversation as resolved
Review

im singular: werden 1 spiel gelöscht? dat passt ja net oder

im singular: werden 1 spiel gelöscht? dat passt ja net oder
Review

fixed

fixed
"error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen",
"error_deleting_game": "Fehler beim Löschen der Spielvorlage, bitte erneut versuchen",
"error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen",
"error_editing_group": "Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen",
"error_reading_file": "Fehler beim Lesen der Datei",

View File

@@ -18,6 +18,9 @@
"@cancel": {
"description": "Cancel button text"
},
"@choose_color": {
"description": "Label for choosing a color"
},
"@choose_game": {
"description": "Label for choosing a game"
},
@@ -27,9 +30,15 @@
"@choose_ruleset": {
"description": "Label for choosing a ruleset"
},
"@color": {
"description": "Color label"
},
"@could_not_add_player": {
"description": "Error message when adding a player fails"
},
"@create_game": {
"description": "Button text to create a game"
},
"@create_group": {
"description": "Button text to create a group"
},
@@ -71,12 +80,21 @@
"@delete_all_data": {
"description": "Confirmation dialog for deleting all data"
},
"@delete_game": {
"description": "Button text to delete a game"
},
"@delete_group": {
"description": "Confirmation dialog for deleting a group"
},
"@delete_match": {
"description": "Button text to delete a match"
},
"@description": {
"description": "Description label"
},
"@edit_game": {
"description": "Button text to edit a game"
},
"@edit_group": {
"description": "Button & Appbar label for editing a group"
},
@@ -92,6 +110,9 @@
"@error_creating_group": {
"description": "Error message when group creation fails"
},
"@error_deleting_game": {
"description": "Error message when game deletion fails"
},
"@error_deleting_group": {
"description": "Error message when group deletion fails"
},
@@ -340,10 +361,21 @@
"app_name": "Tallee",
"best_player": "Best Player",
"cancel": "Cancel",
"choose_color": "Choose Color",
"choose_game": "Choose Game",
"choose_group": "Choose Group",
"choose_ruleset": "Choose Ruleset",
"color": "Color",
"color_blue": "Blue",
"color_green": "Green",
"color_orange": "Orange",
"color_pink": "Pink",
"color_purple": "Purple",
"color_red": "Red",
"color_teal": "Teal",
"color_yellow": "Yellow",
"could_not_add_player": "Could not add player",
"create_game": "Create Game",
"create_group": "Create Group",
"create_match": "Create match",
"create_new_group": "Create new group",
@@ -356,13 +388,17 @@
"days_ago": "{count} days ago",
"delete": "Delete",
"delete_all_data": "Delete all data",
"delete_game": "Delete Game",
"delete_group": "Delete Group",
flixcoo marked this conversation as resolved Outdated

hinter other ein space vergessen?
Kenne mich nicht mit den templates aus

hinter `other` ein space vergessen? Kenne mich nicht mit den templates aus

Das ist richtig so, wenn count =1 dann wird 1 match zurückgegeben, und in allen anderen fällen {count} matches.

Das ist richtig so, wenn count =1 dann wird `1 match` zurückgegeben, und in allen anderen fällen `{count} matches`.
"delete_match": "Delete Match",
"description": "Description",
"edit_game": "Edit Game",
"edit_group": "Edit Group",
"edit_match": "Edit Match",
"enter_points": "Enter points",
"enter_results": "Enter Results",
"error_creating_group": "Error while creating group, please try again",
"error_deleting_game": "Error while deleting game, please try again",
"error_deleting_group": "Error while deleting group, please try again",
"error_editing_group": "Error while editing group, please try again",
"error_reading_file": "Error reading file",

View File

@@ -134,6 +134,12 @@ abstract class AppLocalizations {
/// **'Cancel'**
String get cancel;
/// Label for choosing a color
///
/// In en, this message translates to:
/// **'Choose Color'**
String get choose_color;
/// Label for choosing a game
///
/// In en, this message translates to:
@@ -152,12 +158,72 @@ abstract class AppLocalizations {
/// **'Choose Ruleset'**
String get choose_ruleset;
/// Color label
///
/// In en, this message translates to:
/// **'Color'**
String get color;
/// No description provided for @color_blue.
flixcoo marked this conversation as resolved
Review

sind die descriptions hier nicht mies unnötig?

sind die descriptions hier nicht mies unnötig?
Review

ja, ich entfern die mal

ja, ich entfern die mal
///
/// In en, this message translates to:
/// **'Blue'**
String get color_blue;
/// No description provided for @color_green.
///
/// In en, this message translates to:
/// **'Green'**
String get color_green;
/// No description provided for @color_orange.
///
/// In en, this message translates to:
/// **'Orange'**
String get color_orange;
/// No description provided for @color_pink.
///
/// In en, this message translates to:
/// **'Pink'**
String get color_pink;
/// No description provided for @color_purple.
///
/// In en, this message translates to:
/// **'Purple'**
String get color_purple;
/// No description provided for @color_red.
///
/// In en, this message translates to:
/// **'Red'**
String get color_red;
/// No description provided for @color_teal.
///
/// In en, this message translates to:
/// **'Teal'**
String get color_teal;
/// No description provided for @color_yellow.
///
/// In en, this message translates to:
/// **'Yellow'**
String get color_yellow;
/// Error message when adding a player fails
///
/// In en, this message translates to:
/// **'Could not add player'**
String could_not_add_player(Object playerName);
/// Button text to create a game
///
/// In en, this message translates to:
/// **'Create Game'**
String get create_game;
/// Button text to create a group
///
/// In en, this message translates to:
@@ -230,6 +296,12 @@ abstract class AppLocalizations {
/// **'Delete all data'**
String get delete_all_data;
/// Button text to delete a game
///
/// In en, this message translates to:
/// **'Delete Game'**
String get delete_game;
/// Confirmation dialog for deleting a group
///
/// In en, this message translates to:
@@ -242,6 +314,18 @@ abstract class AppLocalizations {
/// **'Delete Match'**
String get delete_match;
/// Description label
///
/// In en, this message translates to:
/// **'Description'**
String get description;
/// Button text to edit a game
///
/// In en, this message translates to:
/// **'Edit Game'**
String get edit_game;
/// Button & Appbar label for editing a group
///
/// In en, this message translates to:
@@ -272,6 +356,12 @@ abstract class AppLocalizations {
/// **'Error while creating group, please try again'**
String get error_creating_group;
/// Error message when game deletion fails
///
/// In en, this message translates to:
/// **'Error while deleting game, please try again'**
String get error_deleting_game;
/// Error message when group deletion fails
flixcoo marked this conversation as resolved Outdated

bitte weniger vibe coden felix 🤓 🫵

bitte weniger vibe coden felix 🤓 🫵

hä was meinst du? :D

hä was meinst du? :D
///
/// In en, this message translates to:

View File

@@ -26,6 +26,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cancel => 'Abbrechen';
@override
String get choose_color => 'Farbe wählen';
@override
String get choose_game => 'Spielvorlage wählen';
@@ -35,11 +38,41 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get choose_ruleset => 'Regelwerk wählen';
@override
String get color => 'Farbe';
@override
String get color_blue => 'Blau';
@override
String get color_green => 'Grün';
@override
String get color_orange => 'Orange';
@override
String get color_pink => 'Rosa';
@override
String get color_purple => 'Lila';
@override
String get color_red => 'Rot';
@override
String get color_teal => 'Türkis';
@override
String get color_yellow => 'Gelb';
@override
String could_not_add_player(Object playerName) {
return 'Spieler:in $playerName konnte nicht hinzugefügt werden';
}
@override
String get create_game => 'Spielvorlage erstellen';
@override
String get create_group => 'Gruppe erstellen';
@@ -78,12 +111,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get delete_all_data => 'Alle Daten löschen';
@override
String get delete_game => 'Spielvorlage löschen';
@override
String get delete_group => 'Gruppe löschen';
@override
String get delete_match => 'Spiel löschen';
@override
String get description => 'Beschreibung';
@override
String get edit_game => 'Spielvorlage bearbeiten';
@override
String get edit_group => 'Gruppe bearbeiten';
@@ -100,6 +142,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get error_creating_group =>
'Fehler beim Erstellen der Gruppe, bitte erneut versuchen';
@override
String get error_deleting_game =>
'Fehler beim Löschen der Spielvorlage, bitte erneut versuchen';
@override
String get error_deleting_group =>
'Fehler beim Löschen der Gruppe, bitte erneut versuchen';

View File

@@ -26,6 +26,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cancel => 'Cancel';
@override
String get choose_color => 'Choose Color';
@override
String get choose_game => 'Choose Game';
@@ -35,11 +38,41 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get choose_ruleset => 'Choose Ruleset';
@override
String get color => 'Color';
@override
String get color_blue => 'Blue';
@override
String get color_green => 'Green';
@override
String get color_orange => 'Orange';
@override
String get color_pink => 'Pink';
@override
String get color_purple => 'Purple';
@override
String get color_red => 'Red';
@override
String get color_teal => 'Teal';
@override
String get color_yellow => 'Yellow';
@override
String could_not_add_player(Object playerName) {
return 'Could not add player';
}
@override
String get create_game => 'Create Game';
@override
String get create_group => 'Create Group';
@@ -78,12 +111,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get delete_all_data => 'Delete all data';
@override
String get delete_game => 'Delete Game';
@override
String get delete_group => 'Delete Group';
@override
String get delete_match => 'Delete Match';
@override
String get description => 'Description';
@override
String get edit_game => 'Edit Game';
@override
String get edit_group => 'Edit Group';
@@ -100,6 +142,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get error_creating_group =>
'Error while creating group, please try again';
@override
String get error_deleting_game =>
'Error while deleting game, please try again';
@override
String get error_deleting_group =>
'Error while deleting group, please try again';

View File

@@ -1,19 +1,23 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart';
import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart';
import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart';
class ChooseGameView extends StatefulWidget {
/// A view that allows the user to choose a game from a list of available games
/// - [games]: A list of tuples containing the game name, description and ruleset
/// - [initialGameIndex]: The index of the initially selected game
/// - [games]: The list of available games
/// - [initialGameId]: The id of the initially selected game
/// - [onGamesUpdated]: Optional callback invoked when the games are updated
const ChooseGameView({
super.key,
required this.games,
required this.initialGameId,
this.onGamesUpdated,
});
/// A list of tuples containing the game name, description and ruleset
@@ -22,6 +26,9 @@ class ChooseGameView extends StatefulWidget {
/// The id of the initially selected game
final String initialGameId;
/// Optional callback invoked when the games are updated
final VoidCallback? onGamesUpdated;
@override
State<ChooseGameView> createState() => _ChooseGameViewState();
}
@@ -33,9 +40,16 @@ class _ChooseGameViewState extends State<ChooseGameView> {
/// Currently selected game index
late String selectedGameId;
/// Games filtered according to the current search query
late List<Game> filteredGames;
@override
void initState() {
selectedGameId = widget.initialGameId;
// Start with all games visible
filteredGames = List<Game>.from(widget.games);
super.initState();
}
@@ -58,6 +72,30 @@ class _ChooseGameViewState extends State<ChooseGameView> {
);
},
),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
final result = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateGameView(
onGameChanged: () {
widget.onGamesUpdated?.call();
},
),
),
);
if (result != null && result.game != null) {
setState(() {
widget.games.insert(0, result.game);
});
_refreshFromSource();
}
},
),
],
title: Text(loc.choose_game),
),
body: PopScope(
@@ -77,30 +115,63 @@ class _ChooseGameViewState extends State<ChooseGameView> {
child: CustomSearchBar(
controller: searchBarController,
hintText: loc.game_name,
onChanged: (value) {
_applySearchFilter(value);
},
),
),
const SizedBox(height: 5),
Expanded(
child: ListView.builder(
itemCount: widget.games.length,
itemCount: filteredGames.length,
itemBuilder: (BuildContext context, int index) {
final game = filteredGames[index];
return TitleDescriptionListTile(
title: widget.games[index].name,
description: widget.games[index].description,
badgeText: translateRulesetToString(
widget.games[index].ruleset,
context,
),
isHighlighted: selectedGameId == widget.games[index].id,
onPressed: () async {
title: game.name,
description: game.description,
badgeText: translateRulesetToString(game.ruleset, context),
isHighlighted: selectedGameId == game.id,
onTap: () async {
setState(() {
if (selectedGameId != widget.games[index].id) {
selectedGameId = widget.games[index].id;
} else {
if (selectedGameId == game.id) {
selectedGameId = '';
} else {
selectedGameId = game.id;
}
});
},
onLongPress: () async {
final result = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateGameView(
gameToEdit: game,
onGameChanged: () {
widget.onGamesUpdated?.call();
},
),
),
);
if (result != null && result.game != null) {
// Find the index in the original list to mutate
final originalIndex = widget.games.indexWhere(
(g) => g.id == game.id,
);
if (originalIndex == -1) {
return;
}
if (result.delete) {
setState(() {
widget.games.removeAt(originalIndex);
});
} else {
setState(() {
widget.games[originalIndex] = result.game;
});
}
_refreshFromSource();
}
},
);
},
),
@@ -110,4 +181,28 @@ class _ChooseGameViewState extends State<ChooseGameView> {
),
);
}
/// Applies the search filter to the games list based on [query].
void _applySearchFilter(String query) {
final q = query.toLowerCase().trim();
if (q.isEmpty) {
setState(() {
filteredGames = List<Game>.from(widget.games);
});
return;
}
setState(() {
filteredGames = widget.games.where((game) {
final name = game.name.toLowerCase();
final description = game.description.toLowerCase();
return name.contains(q) || description.contains(q);
}).toList();
});
}
/// Re-applies the current filter after the underlying games list changed.
void _refreshFromSource() {
_applySearchFilter(searchBarController.text);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart';
class ChooseColorView extends StatefulWidget {
/// A view that allows the user to choose a color from a list of available game colors
/// - [initialColor]: The initially selected color
const ChooseColorView({super.key, this.initialColor});
/// The initially selected color
final GameColor? initialColor;
@override
State<ChooseColorView> createState() => _ChooseColorViewState();
}
class _ChooseColorViewState extends State<ChooseColorView> {
/// Currently selected color
GameColor? selectedColor;
@override
void initState() {
selectedColor = widget.initialColor;
super.initState();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
const colors = GameColor.values;
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: () {
Navigator.of(context).pop(selectedColor);
},
),
title: Text(loc.choose_color),
),
body: PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (didPop) return;
Navigator.of(context).pop(selectedColor);
},
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 85),
itemCount: colors.length,
itemBuilder: (BuildContext context, int index) {
final color = colors[index];
return TitleDescriptionListTile(
onTap: () {
setState(() {
if (selectedColor == color) {
selectedColor = null;
} else {
selectedColor = color;
}
});
},
title: translateGameColorToString(color, context),
description: '',
isHighlighted: selectedColor == color,
badgeText: ' ', //Breite für Color Badge
badgeColor: getColorFromGameColor(color),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart';
class ChooseRulesetView extends StatefulWidget {
/// A view that allows the user to choose a ruleset from a list of available rulesets
/// - [rulesets]: A list of tuples containing the ruleset and its description
/// - [initialRulesetIndex]: The index of the initially selected ruleset
const ChooseRulesetView({
super.key,
required this.rulesets,
required this.initialRulesetIndex,
});
/// A list of tuples containing the ruleset and its description
final List<(Ruleset, String)> rulesets;
/// The index of the initially selected ruleset
final int initialRulesetIndex;
@override
State<ChooseRulesetView> createState() => _ChooseRulesetViewState();
}
class _ChooseRulesetViewState extends State<ChooseRulesetView> {
/// Currently selected ruleset index
late int selectedRulesetIndex;
@override
void initState() {
selectedRulesetIndex = widget.initialRulesetIndex;
super.initState();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return DefaultTabController(
length: 2,
initialIndex: 0,
child: Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: () {
Navigator.of(context).pop(
selectedRulesetIndex == -1
? null
: widget.rulesets[selectedRulesetIndex].$1,
);
},
),
title: Text(loc.choose_ruleset),
),
body: PopScope(
// This fixes that the Android Back Gesture didn't return the
// selectedRulesetIndex and therefore the selected Ruleset wasn't saved
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (didPop) {
return;
}
Navigator.of(context).pop(
selectedRulesetIndex == -1
? null
: widget.rulesets[selectedRulesetIndex].$1,
);
},
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 85),
itemCount: widget.rulesets.length,
itemBuilder: (BuildContext context, int index) {
return TitleDescriptionListTile(
onTap: () async {
setState(() {
if (selectedRulesetIndex == index) {
selectedRulesetIndex = -1;
} else {
selectedRulesetIndex = index;
}
});
},
title: translateRulesetToString(
widget.rulesets[index].$1,
context,
),
description: widget.rulesets[index].$2,
isHighlighted: selectedRulesetIndex == index,
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,352 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game/choose_color_view.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game/choose_ruleset_view.dart';
import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart';
import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart';
import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
import 'package:tallee/presentation/widgets/tiles/choose_tile.dart';
/// A stateful widget for creating or editing a game.
/// - [gameToEdit] An optional game to prefill the fields
/// - [onGameChanged] Callback to invoke when the game is created or edited
class CreateGameView extends StatefulWidget {
const CreateGameView({
super.key,
this.gameToEdit,
required this.onGameChanged,
});
/// An optional game to prefill the fields
final Game? gameToEdit;
/// Callback to invoke when the game is created or edited
final VoidCallback onGameChanged;
@override
State<CreateGameView> createState() => _CreateGameViewState();
}
class _CreateGameViewState extends State<CreateGameView> {
/// GlobalKey for ScaffoldMessenger to show snackbars
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
/// The database instance for accessing game data.
late final AppDatabase db;
/// The currently selected ruleset for the game.
Ruleset? selectedRuleset;
/// The index of the currently selected ruleset.
int selectedRulesetIndex = -1;
/// A list of available rulesets and their localized names.
late List<(Ruleset, String)> _rulesets;
/// The currently selected color for the game.
GameColor? selectedColor;
/// Controller for the game name input field.
final _gameNameController = TextEditingController();
/// Controller for the game description input field.
final _descriptionController = TextEditingController();
/// The ID of the currently selected group.
late String selectedGroupId;
/// A controller for the search bar input field.
final TextEditingController controller = TextEditingController();
/// A list of groups filtered based on the search query.
late final List<Group> filteredGroups;
@override
void initState() {
super.initState();
db = Provider.of<AppDatabase>(context, listen: false);
_gameNameController.addListener(() => setState(() {}));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_rulesets = [
(
Ruleset.singleWinner,
translateRulesetToString(Ruleset.singleWinner, context),
),
(
Ruleset.singleLoser,
translateRulesetToString(Ruleset.singleLoser, context),
),
(
Ruleset.highestScore,
translateRulesetToString(Ruleset.highestScore, context),
),
(
Ruleset.lowestScore,
translateRulesetToString(Ruleset.lowestScore, context),
),
(
Ruleset.multipleWinners,
translateRulesetToString(Ruleset.multipleWinners, context),
),
];
if (widget.gameToEdit != null) {
_gameNameController.text = widget.gameToEdit!.name;
_descriptionController.text = widget.gameToEdit!.description;
selectedRuleset = widget.gameToEdit!.ruleset;
selectedColor = widget.gameToEdit!.color;
selectedRulesetIndex = _rulesets.indexWhere(
(r) => r.$1 == selectedRuleset,
);
}
}
@override
void dispose() {
_gameNameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var loc = AppLocalizations.of(context);
final isEditing = widget.gameToEdit != null;
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
title: Text(isEditing ? loc.edit_game : loc.create_game),
actions: widget.gameToEdit == null
? []
: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
if (widget.gameToEdit != null) {
showDialog<bool>(
context: context,
builder: (context) => CustomAlertDialog(
title: loc.delete_game,
content: Text(loc.this_cannot_be_undone),
actions: [
CustomDialogAction(
onPressed: () =>
Navigator.of(context).pop(false),
text: loc.cancel,
),
CustomDialogAction(
onPressed: () =>
Navigator.of(context).pop(true),
text: loc.delete,
),
],
),
).then((confirmed) async {
if (confirmed == true && context.mounted) {
bool success = await db.gameDao.deleteGame(
gameId: widget.gameToEdit!.id,
);
if (!context.mounted) return;
if (success) {
widget.onGameChanged.call();
Navigator.of(
context,
).pop((game: widget.gameToEdit, delete: true));
} else {
if (!mounted) return;
showSnackbar(message: loc.error_deleting_game);
}
}
});
}
},
),
],
),
body: SafeArea(
child: Column(
children: [
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
controller: _gameNameController,
maxLength: Constants.MAX_MATCH_NAME_LENGTH,
hintText: loc.game_name,
),
),
ChooseTile(
title: loc.ruleset,
trailingText: selectedRuleset == null
? loc.none
: translateRulesetToString(selectedRuleset!, context),
onPressed: () async {
final result = await Navigator.of(context).push<Ruleset?>(
adaptivePageRoute(
builder: (context) => ChooseRulesetView(
rulesets: _rulesets,
initialRulesetIndex: selectedRulesetIndex,
),
),
);
if (mounted) {
setState(() {
selectedRuleset = result;
selectedRulesetIndex = result == null
? -1
: _rulesets.indexWhere((r) => r.$1 == result);
});
}
},
),
ChooseTile(
title: loc.color,
trailingText: selectedColor == null
? loc.none
: translateGameColorToString(selectedColor!, context),
onPressed: () async {
final result = await Navigator.of(context).push<GameColor?>(
adaptivePageRoute(
builder: (context) =>
ChooseColorView(initialColor: selectedColor),
),
);
if (mounted) {
setState(() {
selectedColor = result;
});
}
},
),
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
controller: _descriptionController,
hintText: loc.description,
minLines: 6,
maxLines: 6,
maxLength: Constants.MAX_GAME_DESCRIPTION_LENGTH,
showCounterText: true,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.all(12.0),
child: CustomWidthButton(
text: isEditing ? loc.edit_game : loc.create_game,
sizeRelativeToWidth: 1,
buttonType: ButtonType.primary,
onPressed:
_gameNameController.text.trim().isNotEmpty &&
selectedRulesetIndex != -1 &&
selectedColor != null
? () async {
Game newGame = Game(
name: _gameNameController.text.trim(),
description: _descriptionController.text.trim(),
ruleset: selectedRuleset!,
color: selectedColor!,
);
if (isEditing) {
await handleGameUpdate(newGame);
} else {
await handleGameCreation(newGame);
}
widget.onGameChanged.call();
if (context.mounted) {
Navigator.of(
context,
).pop((game: newGame, delete: false));
}
}
: null,
),
),
],
),
),
),
);
}
/// Handles updating an existing game in the database.
///
/// [newGame] The updated game object.
Future<void> handleGameUpdate(Game newGame) async {
final oldGame = widget.gameToEdit!;
if (oldGame.name != newGame.name) {
await db.gameDao.updateGameName(
gameId: oldGame.id,
newName: newGame.name,
);
}
if (oldGame.description != newGame.description) {
await db.gameDao.updateGameDescription(
gameId: oldGame.id,
newDescription: newGame.description,
);
}
if (oldGame.ruleset != newGame.ruleset) {
await db.gameDao.updateGameRuleset(
gameId: oldGame.id,
newRuleset: newGame.ruleset,
);
}
if (oldGame.color != newGame.color) {
await db.gameDao.updateGameColor(
gameId: oldGame.id,
newColor: newGame.color,
);
}
if (oldGame.icon != newGame.icon) {
await db.gameDao.updateGameIcon(
gameId: oldGame.id,
newIcon: newGame.icon,
);
}
}
/// Handles creating a new game in the database.
///
/// [newGame] The game object to be created.
Future<void> handleGameCreation(Game newGame) async {
await db.gameDao.addGame(game: newGame);
}
/// Displays a snackbar with the given message and optional action.
///
/// [message] The message to display in the snackbar.
void showSnackbar({required String message}) {
final messenger = _scaffoldMessengerKey.currentState;
if (messenger != null) {
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: CustomTheme.boxColor,
),
);
}
}
}

View File

@@ -115,6 +115,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Match name input field.
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
@@ -123,6 +124,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
maxLength: Constants.MAX_MATCH_NAME_LENGTH,
),
),
// Game selection tile.
ChooseTile(
title: loc.game,
trailingText: selectedGame == null
@@ -146,6 +149,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
});
},
),
// Group selection tile.
ChooseTile(
title: loc.group,
trailingText: selectedGroup == null
@@ -181,6 +186,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
});
},
),
// Player selection widget.
Expanded(
child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'),
@@ -193,6 +200,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
},
),
),
// Create or save button.
CustomWidthButton(
text: buttonText,
sizeRelativeToWidth: 0.95,
@@ -218,16 +227,16 @@ class _CreateMatchViewState extends State<CreateMatchView> {
///
/// Returns `true` if:
/// - A ruleset is selected AND
/// - Either a group is selected OR at least 2 players are selected
/// - Either a group is selected OR at least 2 players are selected.
bool _enableCreateGameButton() {
return (selectedGroup != null ||
(selectedPlayers.length > 1) && selectedGame != null);
}
// If a match was provided to the view, it updates the match in the database
// and navigates back to the previous screen.
// If no match was provided, it creates a new match in the database and
// navigates to the MatchResultView for the newly created match.
/// Handles navigation when the create or save button is pressed.
///
/// If a match is being edited, updates the match in the database.
/// Otherwise, creates a new match and navigates to the MatchResultView.
void buttonNavigation(BuildContext context) async {
if (isEditMode()) {
await updateMatch();
@@ -252,8 +261,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
}
}
/// Updates attributes of the existing match in the database based on the
/// changes made in the edit view.
/// Updates the existing match in the database.
Future<void> updateMatch() async {
final updatedMatch = Match(
id: widget.matchToEdit!.id,
@@ -262,7 +270,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
: _matchNameController.text.trim(),
group: selectedGroup,
players: selectedPlayers,
game: widget.matchToEdit!.game,
game: selectedGame!,
createdAt: widget.matchToEdit!.createdAt,
endedAt: widget.matchToEdit!.endedAt,
notes: widget.matchToEdit!.notes,
@@ -282,6 +290,13 @@ class _CreateMatchViewState extends State<CreateMatchView> {
);
}
if (widget.matchToEdit!.game.id != updatedMatch.game.id) {
await db.matchDao.updateMatchGame(
matchId: widget.matchToEdit!.id,
gameId: updatedMatch.game.id,
);
}
// Add players who are in updatedMatch but not in the original match
for (var player in updatedMatch.players) {
if (!widget.matchToEdit!.players.any((p) => p.id == player.id)) {

View File

@@ -8,12 +8,18 @@ class TextInputField extends StatelessWidget {
/// - [onChanged]: Optional callback invoked when the text in the field changes.
/// - [hintText]: The hint text displayed in the text input field when it is empty
/// - [maxLength]: Optional parameter for maximum length of the input text.
/// - [maxLines]: The maximum number of lines for the text input field. Defaults to 1.
/// - [minLines]: The minimum number of lines for the text input field. Defaults to 1.
/// - [showCounterText]: Whether to show the counter text in the text input field. Defaults to false.
const TextInputField({
super.key,
required this.controller,
required this.hintText,
this.onChanged,
this.maxLength,
this.maxLines = 1,
this.minLines = 1,
this.showCounterText = false,
});
/// The controller for the text input field.
@@ -28,6 +34,15 @@ class TextInputField extends StatelessWidget {
/// Optional parameter for maximum length of the input text.
final int? maxLength;
/// The maximum number of lines for the text input field.
final int? maxLines;
/// The minimum number of lines for the text input field.
final int? minLines;
/// Whether to show the counter text in the text input field.
final bool showCounterText;
@override
Widget build(BuildContext context) {
return TextField(
@@ -35,13 +50,15 @@ class TextInputField extends StatelessWidget {
onChanged: onChanged,
maxLength: maxLength,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
maxLines: maxLines,
minLines: minLines,
decoration: InputDecoration(
filled: true,
fillColor: CustomTheme.boxColor,
hintText: hintText,
hintStyle: const TextStyle(fontSize: 18),
// Hides the character counter
counterText: '',
counterText: showCounterText ? null : '',
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: CustomTheme.boxBorderColor),

View File

@@ -5,7 +5,8 @@ class TitleDescriptionListTile extends StatelessWidget {
/// A list tile widget that displays a title and description, with optional highlighting and badge.
/// - [title]: The title text displayed on the tile.
/// - [description]: The description text displayed below the title.
/// - [onPressed]: The callback invoked when the tile is tapped.
/// - [onTap]: The callback invoked when the tile is tapped.
/// - [onLongPress]: The callback invoked when the tile is tapped.
/// - [isHighlighted]: A boolean to determine if the tile should be highlighted.
/// - [badgeText]: Optional text to display in a badge on the right side of the title.
/// - [badgeColor]: Optional color for the badge background.
@@ -13,7 +14,8 @@ class TitleDescriptionListTile extends StatelessWidget {
super.key,
required this.title,
required this.description,
this.onPressed,
this.onTap,
this.onLongPress,
this.isHighlighted = false,
this.badgeText,
this.badgeColor,
@@ -26,7 +28,10 @@ class TitleDescriptionListTile extends StatelessWidget {
final String description;
/// The callback invoked when the tile is tapped.
final VoidCallback? onPressed;
final VoidCallback? onTap;
/// The callback invoked when the tile is long-pressed.
final VoidCallback? onLongPress;
/// A boolean to determine if the tile should be highlighted.
final bool isHighlighted;
@@ -40,7 +45,8 @@ class TitleDescriptionListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
onTap: onTap,
onLongPress: onLongPress,
child: AnimatedContainer(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),