Merge branch 'development' into feature/202-live-edit-modus
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 49s
Pull Request Pipeline / lint (pull_request) Successful in 51s

# Conflicts:
#	lib/l10n/generated/app_localizations.dart
#	pubspec.yaml
This commit is contained in:
2026-05-09 19:36:21 +02:00
29 changed files with 1759 additions and 714 deletions

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,8 +21,71 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) {
}
}
/// 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
/// 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 const Color(0xFFF7CA28);
case GameColor.purple:
return Colors.purple;
case GameColor.orange:
return const Color(0xFFef681f);
case GameColor.pink:
return Colors.pink;
case GameColor.teal:
return Colors.teal;
}
}
/// Returns [IconData] corresponding to a [Ruleset] enum value.
IconData getRulesetIcon(Ruleset ruleset) {
switch (ruleset) {
case Ruleset.highestScore:
return Icons.arrow_upward;
case Ruleset.lowestScore:
return Icons.arrow_downward;
case Ruleset.singleWinner:
return Icons.emoji_events;
case Ruleset.singleLoser:
return Icons.sentiment_dissatisfied;
case Ruleset.multipleWinners:
return Icons.group;
}
}
/// Counts how many players in the [match] are not part of the group
///
/// Returns the text you append after the group name, e.g. " + 5" or an empty
/// string if there are no extra players
String getExtraPlayerCount(Match match) {
int count = 0;

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

@@ -63,9 +63,8 @@ class CustomTheme {
static BoxDecoration highlightedBoxDecoration = BoxDecoration(
color: boxColor,
border: Border.all(color: primaryColor),
border: Border.all(color: textColor, width: 2),
borderRadius: standardBorderRadiusAll,
boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)],
);
// ==================== Component Themes ====================

View File

@@ -194,4 +194,25 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
final rowsAffected = await query.go();
return rowsAffected > 0;
}
/// Retrieves all games with their respective match counts.
/// Returns a list of tuples (Game, matchCount).
Future<List<(Game, int)>> getGameUsage() async {
final games = await getAllGames();
final results = <(Game, int)>[];
for (final game in games) {
final matchCount =
await (selectOnly(db.matchTable)
..where(db.matchTable.gameId.equals(game.id))
..addColumns([db.matchTable.id.count()]))
.map((row) => row.read(db.matchTable.id.count()))
.getSingle();
results.add((game, matchCount ?? 0));
}
return results;
}
}

View File

@@ -341,9 +341,20 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
);
}
/// Retrieves the number of matches associated with a specific game.
Future<int> getMatchCountByGame({required String gameId}) async {
final count =
await (selectOnly(matchTable)
..where(matchTable.gameId.equals(gameId))
..addColumns([matchTable.id.count()]))
.map((row) => row.read(matchTable.id.count()))
.getSingle();
return count ?? 0;
}
/// Retrieves all matches associated with the given [groupId].
/// Queries the database directly, filtering by [groupId].
Future<List<Match>> getGroupMatches({required String groupId}) async {
Future<List<Match>> getMatchesByGroup({required String groupId}) async {
final query = select(matchTable)..where((m) => m.groupId.equals(groupId));
final rows = await query.get();
@@ -478,4 +489,12 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
final rowsAffected = await query.go();
return rowsAffected > 0;
}
/// Deletes all matches associated with a specific game.
/// Returns the number of matches deleted.
Future<int> deleteMatchesByGame({required String gameId}) async {
final query = delete(matchTable)..where((m) => m.gameId.equals(gameId));
final rowsAffected = await query.go();
return rowsAffected;
}
}

View File

@@ -12,16 +12,15 @@ class Game {
final String icon;
Game({
String? id,
DateTime? createdAt,
required this.name,
required this.ruleset,
String? description,
required this.color,
required this.icon,
this.color = GameColor.orange,
this.description = '',
this.icon = '',
String? id,
DateTime? createdAt,
}) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? clock.now(),
description = description ?? '';
createdAt = createdAt ?? clock.now();
@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,25 @@
"days_ago": "vor {count} Tagen",
"delete": "Löschen",
"delete_all_data": "Alle Daten löschen",
"delete_game": "Spielvorlage löschen",
"delete_game_with_matches_warning": "Wenn du diese Spielvorlage löschst, {count, plural, =1{wird 1 Spiel} other{werden {count} Spiele}} mit dieser Spielvorlage ebenfalls gelöscht.",
"@delete_game_with_matches_warning": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"delete_group": "Gruppe löschen",
"delete_match": "Spiel löschen",
"description": "Beschreibung",
"edit_game": "Spielvorlage bearbeiten",
"edit_group": "Gruppe bearbeiten",
"edit_match": "Gruppe bearbeiten",
"enter_points": "Punkte eingeben",
"enter_results": "Ergebnisse eintragen",
"error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen",
"error_deleting_game": "Fehler beim Löschen der Spielvorlage, bitte erneut versuchen",
"error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen",
"error_editing_group": "Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen",
"error_reading_file": "Fehler beim Lesen der Datei",
@@ -59,6 +82,7 @@
"members": "Mitglieder",
"most_points": "Höchste Punkte",
"no_data_available": "Keine Daten verfügbar",
"no_games_created_yet": "Noch keine Spielvorlagen erstellt",
"no_groups_created_yet": "Noch keine Gruppen erstellt",
"no_licenses_found": "Keine Lizenzen gefunden",
"no_license_text_available": "Kein Lizenztext verfügbar",
@@ -76,7 +100,6 @@
"played_matches": "Gespielte Spiele",
"player_name": "Spieler:innenname",
"players": "Spieler:innen",
"players_count": "{count} Spieler",
"point": "Punkt",
"points": "Punkte",
"privacy_policy": "Datenschutzerklärung",
@@ -105,6 +128,7 @@
"statistics": "Statistiken",
"stats": "Statistiken",
"successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt",
"there_are_no_games_matching_your_search": "Es gibt keine Spielvorlagen, die deiner Suche entspricht",
"there_is_no_group_matching_your_search": "Es gibt keine Gruppe, die deiner Suche entspricht",
"this_cannot_be_undone": "Dies kann nicht rückgängig gemacht werden.",
"tie": "Unentschieden",

View File

@@ -1,349 +1,27 @@
{
"@@locale": "en",
"@all_players": {
"description": "Label for all players list"
},
"@all_players_selected": {
"description": "Message when all players are added to selection"
},
"@amount_of_matches": {
"description": "Label for amount of matches statistic"
},
"@app_name": {
"description": "The name of the App"
},
"@best_player": {
"description": "Label for best player statistic"
},
"@cancel": {
"description": "Cancel button text"
},
"@choose_game": {
"description": "Label for choosing a game"
},
"@choose_group": {
"description": "Label for choosing a group"
},
"@choose_ruleset": {
"description": "Label for choosing a ruleset"
},
"@could_not_add_player": {
"description": "Error message when adding a player fails"
},
"@create_group": {
"description": "Button text to create a group"
},
"@create_match": {
"description": "Button text to create a match"
},
"@create_new_group": {
"description": "Appbar text to create a new group"
},
"@create_new_match": {
"description": "Appbar text to create a new match"
},
"@created_on": {
"description": "Label for creation date"
},
"@data": {
"description": "Data label"
},
"@data_successfully_deleted": {
"description": "Success message after deleting data"
},
"@data_successfully_exported": {
"description": "Success message after exporting data"
},
"@data_successfully_imported": {
"description": "Success message after importing data"
},
"@days_ago": {
"description": "Date format for days ago",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@delete": {
"description": "Delete button text"
},
"@delete_all_data": {
"description": "Confirmation dialog for deleting all data"
},
"@delete_group": {
"description": "Confirmation dialog for deleting a group"
},
"@delete_match": {
"description": "Button text to delete a match"
},
"@edit_group": {
"description": "Button & Appbar label for editing a group"
},
"@edit_match": {
"description": "Button & Appbar label for editing a match"
},
"@enter_points": {
"description": "Label to enter players points"
},
"@enter_results": {
"description": "Button text to enter match results"
},
"@error_creating_group": {
"description": "Error message when group creation fails"
},
"@error_deleting_group": {
"description": "Error message when group deletion fails"
},
"@error_editing_group": {
"description": "Error message when group editing fails"
},
"@error_reading_file": {
"description": "Error message when file cannot be read"
},
"@export_canceled": {
"description": "Message when export is canceled"
},
"@export_data": {
"description": "Export data menu item"
},
"@format_exception": {
"description": "Error message for format exceptions"
},
"@game": {
"description": "Game label"
},
"@game_name": {
"description": "Placeholder for game name search"
},
"@group": {
"description": "Group label"
},
"@group_name": {
"description": "Placeholder for group name input"
},
"@group_profile": {
"description": "Title for group profile view"
},
"@groups": {
"description": "Label for groups"
},
"@home": {
"description": "Home tab label"
},
"@import_canceled": {
"description": "Message when import is canceled"
},
"@import_data": {
"description": "Import data menu item"
},
"@info": {
"description": "Info label"
},
"@invalid_schema": {
"description": "Error message for invalid schema"
},
"@least_points": {
"description": "Title for least points ruleset"
},
"@legal": {
"description": "Legal section header"
},
"@legal_notice": {
"description": "Legal notice menu item"
},
"@licenses": {
"description": "Licenses menu item"
},
"@match_in_progress": {
"description": "Message when match is in progress"
},
"@match_name": {
"description": "Placeholder for match name input"
},
"@match_profile": {
"description": "Title for match profile view"
},
"@matches": {
"description": "Label for matches"
},
"@members": {
"description": "Label for group members"
},
"@most_points": {
"description": "Title for most points ruleset"
},
"@no_data_available": {
"description": "Message when no data in the statistic tiles is given"
},
"@no_groups_created_yet": {
"description": "Message when no groups exist"
},
"@no_licenses_found": {
"description": "Message when no licenses are found"
},
"@no_license_text_available": {
"description": "Message when no license text is available"
},
"@no_matches_created_yet": {
"description": "Message when no matches exist"
},
"@no_players_created_yet": {
"description": "Message when no players exist"
},
"@no_players_found_with_that_name": {
"description": "Message when search returns no results"
},
"@no_players_selected": {
"description": "Message when no players are selected"
},
"@no_recent_matches_available": {
"description": "Message when no recent matches exist"
},
"@no_results_entered_yet": {
"description": "Message when no results have been entered yet"
},
"@no_second_match_available": {
"description": "Message when no second match exists"
},
"@no_statistics_available": {
"description": "Message when no statistics are available, because no matches were played yet"
},
"@none": {
"description": "None option label"
},
"@none_group": {
"description": "None group option label"
},
"@not_available": {
"description": "Abbreviation for not available"
},
"@played_matches": {
"description": "Label for played matches statistic"
},
"@player_name": {
"description": "Placeholder for player name input"
},
"@players": {
"description": "Players label"
},
"@players_count": {
"description": "Shows the number of players",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@points": {
"description": "Points label"
},
"@privacy_policy": {
"description": "Privacy policy menu item"
},
"@quick_create": {
"description": "Title for quick create section"
},
"@recent_matches": {
"description": "Title for recent matches section"
},
"@results": {
"description": "Label for match results"
},
"@ruleset": {
"description": "Ruleset label"
},
"@ruleset_least_points": {
"description": "Description for least points ruleset"
},
"@ruleset_most_points": {
"description": "Description for most points ruleset"
},
"@ruleset_single_loser": {
"description": "Description for single loser ruleset"
},
"@ruleset_single_winner": {
"description": "Description for single winner ruleset"
},
"@save_changes": {
"description": "Save changes button text"
},
"@search_for_groups": {
"description": "Hint text for group search input field"
},
"@search_for_players": {
"description": "Hint text for player search input field"
},
"@select_winner": {
"description": "Label to select the winner"
},
"@select_loser": {
"description": "Label to select the loser"
},
"@selected_players": {
"description": "Shows the number of selected players"
},
"@settings": {
"description": "Label for the App Settings"
},
"@single_loser": {
"description": "Title for single loser ruleset"
},
"@single_winner": {
"description": "Title for single winner ruleset"
},
"@statistics": {
"description": "Statistics tab label"
},
"@stats": {
"description": "Stats tab label (short)"
},
"@successfully_added_player": {
"description": "Success message when adding a player",
"placeholders": {
"playerName": {
"type": "String",
"example": "John"
}
}
},
"@there_is_no_group_matching_your_search": {
"description": "Message when search returns no groups"
},
"@this_cannot_be_undone": {
"description": "Warning message for irreversible actions"
},
"@today_at": {
"description": "Date format for today"
},
"@undo": {
"description": "Undo button text"
},
"@unknown_exception": {
"description": "Error message for unknown exceptions"
},
"@winner": {
"description": "Winner label"
},
"@winrate": {
"description": "Label for winrate statistic"
},
"@wins": {
"description": "Label for wins statistic"
},
"@yesterday_at": {
"description": "Date format for yesterday"
},
"all_players": "All players",
"all_players_selected": "All players selected",
"amount_of_matches": "Amount of Matches",
"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 +34,25 @@
"days_ago": "{count} days ago",
"delete": "Delete",
"delete_all_data": "Delete all data",
"delete_game": "Delete Game",
"delete_game_with_matches_warning": "If you delete this game template, {count, plural, =1{1 match} other{{count} matches}} using this game template will also be deleted.",
"@delete_game_with_matches_warning": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"delete_group": "Delete Group",
"delete_match": "Delete Match",
"description": "Description",
"edit_game": "Edit Game",
"edit_group": "Edit Group",
"edit_match": "Edit Match",
"enter_points": "Enter points",
"enter_results": "Enter Results",
"error_creating_group": "Error while creating group, please try again",
"error_deleting_game": "Error while deleting game, please try again",
"error_deleting_group": "Error while deleting group, please try again",
"error_editing_group": "Error while editing group, please try again",
"error_reading_file": "Error reading file",
@@ -393,6 +83,7 @@
"members": "Members",
"most_points": "Most Points",
"no_data_available": "No data available",
"no_games_created_yet": "No games created yet",
"no_groups_created_yet": "No groups created yet",
"no_licenses_found": "No licenses found",
"no_license_text_available": "No license text available",
@@ -410,7 +101,6 @@
"played_matches": "Played Matches",
"player_name": "Player name",
"players": "Players",
"players_count": "{count} Players",
"point": "Point",
"points": "Points",
"privacy_policy": "Privacy Policy",
@@ -438,6 +128,16 @@
"statistics": "Statistics",
"stats": "Stats",
"successfully_added_player": "Successfully added player {playerName}",
"@successfully_added_player": {
"description": "Success message when adding a player",
"placeholders": {
"playerName": {
"type": "String",
"example": "John"
}
}
},
"there_are_no_games_matching_your_search": "There are no games matching your search",
"there_is_no_group_matching_your_search": "There is no group matching your search",
"this_cannot_be_undone": "This can't be undone.",
"tie": "Tie",

View File

@@ -98,583 +98,667 @@ abstract class AppLocalizations {
Locale('en'),
];
/// Label for all players list
/// No description provided for @all_players.
///
/// In en, this message translates to:
/// **'All players'**
String get all_players;
/// Message when all players are added to selection
/// No description provided for @all_players_selected.
///
/// In en, this message translates to:
/// **'All players selected'**
String get all_players_selected;
/// Label for amount of matches statistic
/// No description provided for @amount_of_matches.
///
/// In en, this message translates to:
/// **'Amount of Matches'**
String get amount_of_matches;
/// The name of the App
/// No description provided for @app_name.
///
/// In en, this message translates to:
/// **'Tallee'**
String get app_name;
/// Label for best player statistic
/// No description provided for @best_player.
///
/// In en, this message translates to:
/// **'Best Player'**
String get best_player;
/// Cancel button text
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// Label for choosing a game
/// No description provided for @choose_color.
///
/// In en, this message translates to:
/// **'Choose Color'**
String get choose_color;
/// No description provided for @choose_game.
///
/// In en, this message translates to:
/// **'Choose Game'**
String get choose_game;
/// Label for choosing a group
/// No description provided for @choose_group.
///
/// In en, this message translates to:
/// **'Choose Group'**
String get choose_group;
/// Label for choosing a ruleset
/// No description provided for @choose_ruleset.
///
/// In en, this message translates to:
/// **'Choose Ruleset'**
String get choose_ruleset;
/// Error message when adding a player fails
/// No description provided for @color.
///
/// In en, this message translates to:
/// **'Color'**
String get color;
/// No description provided for @color_blue.
///
/// In en, this message translates to:
/// **'Blue'**
String get color_blue;
/// No description provided for @color_green.
///
/// In en, this message translates to:
/// **'Green'**
String get color_green;
/// No description provided for @color_orange.
///
/// In en, this message translates to:
/// **'Orange'**
String get color_orange;
/// No description provided for @color_pink.
///
/// In en, this message translates to:
/// **'Pink'**
String get color_pink;
/// No description provided for @color_purple.
///
/// In en, this message translates to:
/// **'Purple'**
String get color_purple;
/// No description provided for @color_red.
///
/// In en, this message translates to:
/// **'Red'**
String get color_red;
/// No description provided for @color_teal.
///
/// In en, this message translates to:
/// **'Teal'**
String get color_teal;
/// No description provided for @color_yellow.
///
/// In en, this message translates to:
/// **'Yellow'**
String get color_yellow;
/// No description provided for @could_not_add_player.
///
/// In en, this message translates to:
/// **'Could not add player'**
String could_not_add_player(Object playerName);
/// Button text to create a group
/// No description provided for @create_game.
///
/// In en, this message translates to:
/// **'Create Game'**
String get create_game;
/// No description provided for @create_group.
///
/// In en, this message translates to:
/// **'Create Group'**
String get create_group;
/// Button text to create a match
/// No description provided for @create_match.
///
/// In en, this message translates to:
/// **'Create match'**
String get create_match;
/// Appbar text to create a new group
/// No description provided for @create_new_group.
///
/// In en, this message translates to:
/// **'Create new group'**
String get create_new_group;
/// Label for creation date
/// No description provided for @created_on.
///
/// In en, this message translates to:
/// **'Created on'**
String get created_on;
/// Appbar text to create a new match
/// No description provided for @create_new_match.
///
/// In en, this message translates to:
/// **'Create new match'**
String get create_new_match;
/// Data label
/// No description provided for @data.
///
/// In en, this message translates to:
/// **'Data'**
String get data;
/// Success message after deleting data
/// No description provided for @data_successfully_deleted.
///
/// In en, this message translates to:
/// **'Data successfully deleted'**
String get data_successfully_deleted;
/// Success message after exporting data
/// No description provided for @data_successfully_exported.
///
/// In en, this message translates to:
/// **'Data successfully exported'**
String get data_successfully_exported;
/// Success message after importing data
/// No description provided for @data_successfully_imported.
///
/// In en, this message translates to:
/// **'Data successfully imported'**
String get data_successfully_imported;
/// Date format for days ago
/// No description provided for @days_ago.
///
/// In en, this message translates to:
/// **'{count} days ago'**
String days_ago(int count);
String days_ago(Object count);
/// Delete button text
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// Confirmation dialog for deleting all data
/// No description provided for @delete_all_data.
///
/// In en, this message translates to:
/// **'Delete all data'**
String get delete_all_data;
/// Confirmation dialog for deleting a group
/// No description provided for @delete_game.
///
/// In en, this message translates to:
/// **'Delete Game'**
String get delete_game;
/// No description provided for @delete_game_with_matches_warning.
///
/// In en, this message translates to:
/// **'If you delete this game template, {count, plural, =1{1 match} other{{count} matches}} using this game template will also be deleted.'**
String delete_game_with_matches_warning(int count);
/// No description provided for @delete_group.
///
/// In en, this message translates to:
/// **'Delete Group'**
String get delete_group;
/// Button text to delete a match
/// No description provided for @delete_match.
///
/// In en, this message translates to:
/// **'Delete Match'**
String get delete_match;
/// Button & Appbar label for editing a group
/// No description provided for @description.
///
/// In en, this message translates to:
/// **'Description'**
String get description;
/// No description provided for @edit_game.
///
/// In en, this message translates to:
/// **'Edit Game'**
String get edit_game;
/// No description provided for @edit_group.
///
/// In en, this message translates to:
/// **'Edit Group'**
String get edit_group;
/// Button & Appbar label for editing a match
/// No description provided for @edit_match.
///
/// In en, this message translates to:
/// **'Edit Match'**
String get edit_match;
/// Label to enter players points
/// No description provided for @enter_points.
///
/// In en, this message translates to:
/// **'Enter points'**
String get enter_points;
/// Button text to enter match results
/// No description provided for @enter_results.
///
/// In en, this message translates to:
/// **'Enter Results'**
String get enter_results;
/// Error message when group creation fails
/// No description provided for @error_creating_group.
///
/// In en, this message translates to:
/// **'Error while creating group, please try again'**
String get error_creating_group;
/// Error message when group deletion fails
/// No description provided for @error_deleting_game.
///
/// In en, this message translates to:
/// **'Error while deleting game, please try again'**
String get error_deleting_game;
/// No description provided for @error_deleting_group.
///
/// In en, this message translates to:
/// **'Error while deleting group, please try again'**
String get error_deleting_group;
/// Error message when group editing fails
/// No description provided for @error_editing_group.
///
/// In en, this message translates to:
/// **'Error while editing group, please try again'**
String get error_editing_group;
/// Error message when file cannot be read
/// No description provided for @error_reading_file.
///
/// In en, this message translates to:
/// **'Error reading file'**
String get error_reading_file;
/// No description provided for @exit_view.
///
/// In en, this message translates to:
/// **'Exit View'**
String get exit_view;
/// Message when export is canceled
/// No description provided for @export_canceled.
///
/// In en, this message translates to:
/// **'Export canceled'**
String get export_canceled;
/// Export data menu item
/// No description provided for @export_data.
///
/// In en, this message translates to:
/// **'Export data'**
String get export_data;
/// Error message for format exceptions
/// No description provided for @format_exception.
///
/// In en, this message translates to:
/// **'Format Exception (see console)'**
String get format_exception;
/// Game label
/// No description provided for @game.
///
/// In en, this message translates to:
/// **'Game'**
String get game;
/// Placeholder for game name search
/// No description provided for @game_name.
///
/// In en, this message translates to:
/// **'Game Name'**
String get game_name;
/// Group label
/// No description provided for @group.
///
/// In en, this message translates to:
/// **'Group'**
String get group;
/// Placeholder for group name input
/// No description provided for @group_name.
///
/// In en, this message translates to:
/// **'Group name'**
String get group_name;
/// Title for group profile view
/// No description provided for @group_profile.
///
/// In en, this message translates to:
/// **'Group Profile'**
String get group_profile;
/// Label for groups
/// No description provided for @groups.
///
/// In en, this message translates to:
/// **'Groups'**
String get groups;
/// Home tab label
/// No description provided for @home.
///
/// In en, this message translates to:
/// **'Home'**
String get home;
/// Message when import is canceled
/// No description provided for @import_canceled.
///
/// In en, this message translates to:
/// **'Import canceled'**
String get import_canceled;
/// Import data menu item
/// No description provided for @import_data.
///
/// In en, this message translates to:
/// **'Import data'**
String get import_data;
/// Info label
/// No description provided for @info.
///
/// In en, this message translates to:
/// **'Info'**
String get info;
/// Error message for invalid schema
/// No description provided for @invalid_schema.
///
/// In en, this message translates to:
/// **'Invalid Schema'**
String get invalid_schema;
/// Title for least points ruleset
/// No description provided for @least_points.
///
/// In en, this message translates to:
/// **'Least Points'**
String get least_points;
/// Legal section header
/// No description provided for @legal.
///
/// In en, this message translates to:
/// **'Legal'**
String get legal;
/// Legal notice menu item
/// No description provided for @legal_notice.
///
/// In en, this message translates to:
/// **'Legal Notice'**
String get legal_notice;
/// Licenses menu item
/// No description provided for @licenses.
///
/// In en, this message translates to:
/// **'Licenses'**
String get licenses;
/// No description provided for @live_edit_mode.
///
/// In en, this message translates to:
/// **'Live Edit Mode'**
String get live_edit_mode;
/// Message when match is in progress
/// No description provided for @match_in_progress.
///
/// In en, this message translates to:
/// **'Match in progress...'**
String get match_in_progress;
/// Placeholder for match name input
/// No description provided for @match_name.
///
/// In en, this message translates to:
/// **'Match name'**
String get match_name;
/// Title for match profile view
/// No description provided for @match_profile.
///
/// In en, this message translates to:
/// **'Match Profile'**
String get match_profile;
/// Label for matches
/// No description provided for @matches.
///
/// In en, this message translates to:
/// **'Matches'**
String get matches;
/// Label for group members
/// No description provided for @members.
///
/// In en, this message translates to:
/// **'Members'**
String get members;
/// Title for most points ruleset
/// No description provided for @most_points.
///
/// In en, this message translates to:
/// **'Most Points'**
String get most_points;
/// Message when no data in the statistic tiles is given
/// No description provided for @no_data_available.
///
/// In en, this message translates to:
/// **'No data available'**
String get no_data_available;
/// Message when no groups exist
/// No description provided for @no_games_created_yet.
///
/// In en, this message translates to:
/// **'No games created yet'**
String get no_games_created_yet;
/// No description provided for @no_groups_created_yet.
///
/// In en, this message translates to:
/// **'No groups created yet'**
String get no_groups_created_yet;
/// Message when no licenses are found
/// No description provided for @no_licenses_found.
///
/// In en, this message translates to:
/// **'No licenses found'**
String get no_licenses_found;
/// Message when no license text is available
/// No description provided for @no_license_text_available.
///
/// In en, this message translates to:
/// **'No license text available'**
String get no_license_text_available;
/// Message when no matches exist
/// No description provided for @no_matches_created_yet.
///
/// In en, this message translates to:
/// **'No matches created yet'**
String get no_matches_created_yet;
/// Message when no players exist
/// No description provided for @no_players_created_yet.
///
/// In en, this message translates to:
/// **'No players created yet'**
String get no_players_created_yet;
/// Message when search returns no results
/// No description provided for @no_players_found_with_that_name.
///
/// In en, this message translates to:
/// **'No players found with that name'**
String get no_players_found_with_that_name;
/// Message when no players are selected
/// No description provided for @no_players_selected.
///
/// In en, this message translates to:
/// **'No players selected'**
String get no_players_selected;
/// Message when no recent matches exist
/// No description provided for @no_recent_matches_available.
///
/// In en, this message translates to:
/// **'No recent matches available'**
String get no_recent_matches_available;
/// Message when no results have been entered yet
/// No description provided for @no_results_entered_yet.
///
/// In en, this message translates to:
/// **'No results entered yet'**
String get no_results_entered_yet;
/// Message when no second match exists
/// No description provided for @no_second_match_available.
///
/// In en, this message translates to:
/// **'No second match available'**
String get no_second_match_available;
/// Message when no statistics are available, because no matches were played yet
/// No description provided for @no_statistics_available.
///
/// In en, this message translates to:
/// **'No statistics available'**
String get no_statistics_available;
/// None option label
/// No description provided for @none.
///
/// In en, this message translates to:
/// **'None'**
String get none;
/// None group option label
/// No description provided for @none_group.
///
/// In en, this message translates to:
/// **'None'**
String get none_group;
/// Abbreviation for not available
/// No description provided for @not_available.
///
/// In en, this message translates to:
/// **'Not available'**
String get not_available;
/// Label for played matches statistic
/// No description provided for @played_matches.
///
/// In en, this message translates to:
/// **'Played Matches'**
String get played_matches;
/// Placeholder for player name input
/// No description provided for @player_name.
///
/// In en, this message translates to:
/// **'Player name'**
String get player_name;
/// Players label
/// No description provided for @players.
///
/// In en, this message translates to:
/// **'Players'**
String get players;
/// Shows the number of players
///
/// In en, this message translates to:
/// **'{count} Players'**
String players_count(int count);
/// No description provided for @point.
///
/// In en, this message translates to:
/// **'Point'**
String get point;
/// Points label
/// No description provided for @points.
///
/// In en, this message translates to:
/// **'Points'**
String get points;
/// Privacy policy menu item
/// No description provided for @privacy_policy.
///
/// In en, this message translates to:
/// **'Privacy Policy'**
String get privacy_policy;
/// Title for quick create section
/// No description provided for @quick_create.
///
/// In en, this message translates to:
/// **'Quick Create'**
String get quick_create;
/// Title for recent matches section
/// No description provided for @recent_matches.
///
/// In en, this message translates to:
/// **'Recent Matches'**
String get recent_matches;
/// Label for match results
/// No description provided for @results.
///
/// In en, this message translates to:
/// **'Results'**
String get results;
/// Ruleset label
/// No description provided for @ruleset.
///
/// In en, this message translates to:
/// **'Ruleset'**
String get ruleset;
/// Description for least points ruleset
/// No description provided for @ruleset_least_points.
///
/// In en, this message translates to:
/// **'Inverse scoring: the player with the fewest points wins.'**
String get ruleset_least_points;
/// Description for most points ruleset
/// No description provided for @ruleset_most_points.
///
/// In en, this message translates to:
/// **'Traditional ruleset: the player with the most points wins.'**
String get ruleset_most_points;
/// Description for single loser ruleset
/// No description provided for @ruleset_single_loser.
///
/// In en, this message translates to:
/// **'Exactly one loser is determined; last place receives the penalty or consequence.'**
String get ruleset_single_loser;
/// Description for single winner ruleset
/// No description provided for @ruleset_single_winner.
///
/// In en, this message translates to:
/// **'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'**
String get ruleset_single_winner;
/// Save changes button text
/// No description provided for @save_changes.
///
/// In en, this message translates to:
/// **'Save Changes'**
String get save_changes;
/// Hint text for group search input field
/// No description provided for @search_for_groups.
///
/// In en, this message translates to:
/// **'Search for groups'**
String get search_for_groups;
/// Hint text for player search input field
/// No description provided for @search_for_players.
///
/// In en, this message translates to:
/// **'Search for players'**
String get search_for_players;
/// Label to select the winner
/// No description provided for @select_winner.
///
/// In en, this message translates to:
/// **'Select Winner'**
String get select_winner;
/// Label to select the loser
/// No description provided for @select_loser.
///
/// In en, this message translates to:
/// **'Select Loser'**
String get select_loser;
/// Shows the number of selected players
/// No description provided for @selected_players.
///
/// In en, this message translates to:
/// **'Selected players'**
String get selected_players;
/// Label for the App Settings
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// Title for single loser ruleset
/// No description provided for @single_loser.
///
/// In en, this message translates to:
/// **'Single Loser'**
String get single_loser;
/// Title for single winner ruleset
/// No description provided for @single_winner.
///
/// In en, this message translates to:
/// **'Single Winner'**
@@ -704,13 +788,13 @@ abstract class AppLocalizations {
/// **'Multiple Winners'**
String get multiple_winners;
/// Statistics tab label
/// No description provided for @statistics.
///
/// In en, this message translates to:
/// **'Statistics'**
String get statistics;
/// Stats tab label (short)
/// No description provided for @stats.
///
/// In en, this message translates to:
/// **'Stats'**
@@ -722,13 +806,19 @@ abstract class AppLocalizations {
/// **'Successfully added player {playerName}'**
String successfully_added_player(String playerName);
/// Message when search returns no groups
/// No description provided for @there_are_no_games_matching_your_search.
///
/// In en, this message translates to:
/// **'There are no games matching your search'**
String get there_are_no_games_matching_your_search;
/// No description provided for @there_is_no_group_matching_your_search.
///
/// In en, this message translates to:
/// **'There is no group matching your search'**
String get there_is_no_group_matching_your_search;
/// Warning message for irreversible actions
/// No description provided for @this_cannot_be_undone.
///
/// In en, this message translates to:
/// **'This can\'t be undone.'**
@@ -740,43 +830,43 @@ abstract class AppLocalizations {
/// **'Tie'**
String get tie;
/// Date format for today
/// No description provided for @today_at.
///
/// In en, this message translates to:
/// **'Today at'**
String get today_at;
/// Undo button text
/// No description provided for @undo.
///
/// In en, this message translates to:
/// **'Undo'**
String get undo;
/// Error message for unknown exceptions
/// No description provided for @unknown_exception.
///
/// In en, this message translates to:
/// **'Unknown Exception (see console)'**
String get unknown_exception;
/// Winner label
/// No description provided for @winner.
///
/// In en, this message translates to:
/// **'Winner'**
String get winner;
/// Label for winrate statistic
/// No description provided for @winrate.
///
/// In en, this message translates to:
/// **'Winrate'**
String get winrate;
/// Label for wins statistic
/// No description provided for @wins.
///
/// In en, this message translates to:
/// **'Wins'**
String get wins;
/// Date format for yesterday
/// No description provided for @yesterday_at.
///
/// In en, this message translates to:
/// **'Yesterday at'**

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';
@@ -68,7 +101,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get data_successfully_imported => 'Daten erfolgreich importiert';
@override
String days_ago(int count) {
String days_ago(Object count) {
return 'vor $count Tagen';
}
@@ -78,12 +111,32 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get delete_all_data => 'Alle Daten löschen';
@override
String get delete_game => 'Spielvorlage löschen';
@override
String delete_game_with_matches_warning(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'werden $count Spiele',
one: 'wird 1 Spiel',
);
return 'Wenn du diese Spielvorlage löschst, $_temp0 mit dieser Spielvorlage ebenfalls gelöscht.';
}
@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 +153,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';
@@ -192,6 +249,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get no_data_available => 'Keine Daten verfügbar';
@override
String get no_games_created_yet => 'Noch keine Spielvorlagen erstellt';
@override
String get no_groups_created_yet => 'Noch keine Gruppen erstellt';
@@ -244,11 +304,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get players => 'Spieler:innen';
@override
String players_count(int count) {
return '$count Spieler';
}
@override
String get point => 'Punkt';
@@ -336,6 +391,10 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Spieler:in $playerName erfolgreich hinzugefügt';
}
@override
String get there_are_no_games_matching_your_search =>
'Es gibt keine Spielvorlagen, die deiner Suche entspricht';
@override
String get there_is_no_group_matching_your_search =>
'Es gibt keine Gruppe, die deiner Suche entspricht';

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';
@@ -68,7 +101,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get data_successfully_imported => 'Data successfully imported';
@override
String days_ago(int count) {
String days_ago(Object count) {
return '$count days ago';
}
@@ -78,12 +111,32 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get delete_all_data => 'Delete all data';
@override
String get delete_game => 'Delete Game';
@override
String delete_game_with_matches_warning(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count matches',
one: '1 match',
);
return 'If you delete this game template, $_temp0 using this game template will also be deleted.';
}
@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 +153,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';
@@ -192,6 +249,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get no_data_available => 'No data available';
@override
String get no_games_created_yet => 'No games created yet';
@override
String get no_groups_created_yet => 'No groups created yet';
@@ -244,11 +304,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get players => 'Players';
@override
String players_count(int count) {
return '$count Players';
}
@override
String get point => 'Point';
@@ -336,6 +391,10 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Successfully added player $playerName';
}
@override
String get there_are_no_games_matching_your_search =>
'There are no games matching your search';
@override
String get there_is_no_group_matching_your_search =>
'There is no group matching your search';

View File

@@ -197,7 +197,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
/// obsolete. For each such match, the group association is removed by setting
/// its [groupId] to null.
Future<void> deleteObsoleteMatchGroupRelations() async {
final groupMatches = await db.matchDao.getGroupMatches(
final groupMatches = await db.matchDao.getMatchesByGroup(
groupId: widget.groupToEdit!.id,
);

View File

@@ -244,7 +244,9 @@ class _GroupDetailViewState extends State<GroupDetailView> {
/// Loads statistics for this group
Future<void> _loadStatistics() async {
isLoading = true;
final groupMatches = await db.matchDao.getGroupMatches(groupId: _group.id);
final groupMatches = await db.matchDao.getMatchesByGroup(
groupId: _group.id,
);
setState(() {
totalMatches = groupMatches.length;

View File

@@ -1,19 +1,26 @@
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/custom_theme.dart';
import 'package:tallee/data/db/database.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_view.dart';
import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart';
import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart';
import 'package:tallee/presentation/widgets/tiles/game_tile.dart';
import 'package:tallee/presentation/widgets/top_centered_message.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,20 +29,37 @@ 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();
}
class _ChooseGameViewState extends State<ChooseGameView> {
late final AppDatabase db;
late List<(Game, int)> gameCounts = [];
/// Controller for the search bar
final TextEditingController searchBarController = TextEditingController();
/// Currently selected game index
late String selectedGameId;
/// Games filtered according to the current search query
late List<Game> filteredGames;
@override
void initState() {
db = Provider.of<AppDatabase>(context, listen: false);
fetchGameCounts();
selectedGameId = widget.initialGameId;
// Start with all games visible
filteredGames = List<Game>.from(widget.games);
super.initState();
}
@@ -58,6 +82,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(
@@ -72,37 +120,101 @@ class _ChooseGameViewState extends State<ChooseGameView> {
},
child: Column(
children: [
// Search Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: CustomSearchBar(
controller: searchBarController,
hintText: loc.game_name,
onChanged: (value) {
_applySearchFilter(value);
},
),
),
const SizedBox(height: 5),
// Game list
Expanded(
child: ListView.builder(
itemCount: widget.games.length,
itemBuilder: (BuildContext context, int index) {
return TitleDescriptionListTile(
title: widget.games[index].name,
description: widget.games[index].description,
badgeText: translateRulesetToString(
widget.games[index].ruleset,
child: Visibility(
visible: filteredGames.isNotEmpty,
replacement: Visibility(
visible: widget.games.isNotEmpty,
replacement: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: loc.no_games_created_yet,
),
child: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: AppLocalizations.of(
context,
),
isHighlighted: selectedGameId == widget.games[index].id,
onPressed: () async {
setState(() {
if (selectedGameId != widget.games[index].id) {
selectedGameId = widget.games[index].id;
} else {
selectedGameId = '';
).there_are_no_games_matching_your_search,
),
),
child: ListView.builder(
itemCount: filteredGames.length,
itemBuilder: (BuildContext context, int index) {
final game = filteredGames[index];
return GameTile(
title: game.name,
description: game.description,
badgeText: translateRulesetToString(
game.ruleset,
context,
),
badgeColor: getColorFromGameColor(game.color),
isHighlighted: selectedGameId == game.id,
onTap: () async {
setState(() {
if (selectedGameId == game.id) {
selectedGameId = '';
} else {
selectedGameId = game.id;
}
});
},
onLongPress: () async {
final result = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateGameView(
gameToEdit: game,
matchCount: getMatchCount(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(() {
// deselect the game
if (selectedGameId == game.id) {
selectedGameId = '';
}
widget.games.removeAt(originalIndex);
widget.onGamesUpdated?.call();
});
} else {
setState(() {
widget.games[originalIndex] = result.game;
});
}
_refreshFromSource();
}
});
},
);
},
},
);
},
),
),
),
],
@@ -110,4 +222,39 @@ 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);
}
Future<void> fetchGameCounts() async {
gameCounts = await db.gameDao.getGameUsage();
}
// Returns the number of matches that use the given [game].
int getMatchCount(Game game) {
return gameCounts
.firstWhere((gc) => gc.$1.id == game.id, orElse: () => (game, 0))
.$2;
}
}

View File

@@ -0,0 +1,520 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_popup/flutter_popup.dart';
import 'package:provider/provider.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/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,
required this.onGameChanged,
this.gameToEdit,
this.matchCount = 0,
});
/// Callback to invoke when the game is created or edited
final VoidCallback onGameChanged;
/// An optional game to prefill the fields
final Game? gameToEdit;
final int matchCount;
@override
State<CreateGameView> createState() => _CreateGameViewState();
}
class _CreateGameViewState extends State<CreateGameView> {
/// GlobalKey for ScaffoldMessenger to show snackbars
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
late final AppDatabase db;
late List<(Ruleset, String)> _rulesets;
Ruleset? selectedRuleset = Ruleset.singleWinner;
late List<(GameColor, String)> _colors;
GameColor? selectedColor = GameColor.orange;
/// 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),
),
];
_colors = [
(GameColor.green, translateGameColorToString(GameColor.green, context)),
(GameColor.teal, translateGameColorToString(GameColor.teal, context)),
(GameColor.blue, translateGameColorToString(GameColor.blue, context)),
(GameColor.purple, translateGameColorToString(GameColor.purple, context)),
(GameColor.pink, translateGameColorToString(GameColor.pink, context)),
(GameColor.red, translateGameColorToString(GameColor.red, context)),
(GameColor.orange, translateGameColorToString(GameColor.orange, context)),
(GameColor.yellow, translateGameColorToString(GameColor.yellow, context)),
];
if (widget.gameToEdit != null) {
_gameNameController.text = widget.gameToEdit!.name;
_descriptionController.text = widget.gameToEdit!.description;
selectedRuleset = widget.gameToEdit!.ruleset;
selectedColor = widget.gameToEdit!.color;
selectedRuleset = widget.gameToEdit!.ruleset;
}
}
@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: [
if (isEditMode())
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
if (!context.mounted) return;
// Build the dialog content based on match count
final String dialogContent = widget.matchCount > 0
? loc.delete_game_with_matches_warning(widget.matchCount)
: loc.this_cannot_be_undone;
showDialog<bool>(
context: context,
builder: (context) => CustomAlertDialog(
title: loc.delete_game,
content: Text(
dialogContent,
style: const TextStyle(fontSize: 15),
),
actions: [
CustomDialogAction(
isDestructive: true,
onPressed: () => Navigator.of(context).pop(true),
text: loc.delete,
),
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(false),
buttonType: ButtonType.secondary,
text: loc.cancel,
),
],
),
).then((confirmed) async {
if (confirmed == true && context.mounted) {
// Delete assocaited matches
if (widget.matchCount > 0) {
await db.matchDao.deleteMatchesByGame(
gameId: widget.gameToEdit!.id,
);
}
// Delete the targetted game
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: [
// Game name input field
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
controller: _gameNameController,
maxLength: Constants.MAX_MATCH_NAME_LENGTH,
hintText: loc.game_name,
),
),
// Choose ruleset tile
if (!isEditMode())
ChooseTile(title: loc.ruleset, trailing: getColorDropdown(loc)),
// Choose color tile
ChooseTile(title: loc.color, trailing: getRulesetDropdown(loc)),
// Description input field
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(),
// Create/Edit game button
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 &&
selectedRuleset != null &&
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, name: newGame.name);
}
if (oldGame.description != newGame.description) {
await db.gameDao.updateGameDescription(
gameId: oldGame.id,
description: newGame.description,
);
}
if (oldGame.ruleset != newGame.ruleset) {
await db.gameDao.updateGameRuleset(
gameId: oldGame.id,
ruleset: newGame.ruleset,
);
}
if (oldGame.color != newGame.color) {
await db.gameDao.updateGameColor(
gameId: oldGame.id,
color: newGame.color,
);
}
if (oldGame.icon != newGame.icon) {
await db.gameDao.updateGameIcon(gameId: oldGame.id, icon: 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,
),
);
}
}
bool isEditMode() {
return widget.gameToEdit != null;
}
Widget getRulesetDropdown(AppLocalizations loc) {
return CustomPopup(
showArrow: true,
arrowColor: CustomTheme.boxBorderColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
barrierColor: Colors.transparent,
contentDecoration: CustomTheme.standardBoxDecoration,
content: StatefulBuilder(
builder: (context, setPopupState) => SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
_rulesets.length,
(index) => GestureDetector(
onTap: () {
setState(() {
selectedRuleset = _rulesets[index].$1;
});
setPopupState(() {});
},
child: Column(
children: [
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
color: selectedRuleset == _rulesets[index].$1
? CustomTheme.textColor.withAlpha(20)
: Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: Row(
spacing: 8,
children: [
Icon(getRulesetIcon(_rulesets[index].$1), size: 16),
Text(
_rulesets[index].$2,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 15,
),
),
],
),
),
),
if (index < _rulesets.length - 1)
const Divider(indent: 15, endIndent: 15),
],
),
),
),
),
),
),
child: Row(
spacing: 8,
children: [
Icon(getRulesetIcon(selectedRuleset!), size: 16),
Padding(
padding: const EdgeInsets.only(right: 5),
child: Text(
translateRulesetToString(selectedRuleset!, context),
textAlign: TextAlign.right,
),
),
Transform.rotate(
angle: pi / 2,
child: const Icon(Icons.arrow_forward_ios, size: 16),
),
],
),
);
}
Widget getColorDropdown(AppLocalizations loc) {
return CustomPopup(
showArrow: true,
arrowColor: CustomTheme.boxBorderColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
barrierColor: Colors.transparent,
contentDecoration: CustomTheme.standardBoxDecoration,
content: StatefulBuilder(
builder: (context, setPopupState) => SizedBox(
width: 150,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
_colors.length,
(index) => GestureDetector(
onTap: () {
setState(() {
selectedColor = _colors[index].$1;
});
setPopupState(() {});
},
child: Column(
children: [
// Selected Highlighting
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
color: selectedColor == _colors[index].$1
? CustomTheme.textColor.withAlpha(20)
: Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
spacing: 8,
children: selectedColor == null
? [Text(loc.none)]
: [
Container(
width: 16,
height: 16,
margin: const EdgeInsets.only(left: 12),
decoration: BoxDecoration(
color: getColorFromGameColor(
_colors[index].$1,
),
shape: BoxShape.circle,
),
),
Text(
_colors[index].$2,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 15,
),
),
],
),
),
),
if (index < _colors.length - 1)
const Divider(indent: 15, endIndent: 15),
],
),
),
),
),
),
),
child: Row(
spacing: 8,
children: [
// Selected Color
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: getColorFromGameColor(selectedColor!),
shape: BoxShape.circle,
),
),
Padding(
padding: const EdgeInsets.only(right: 5),
child: Text(translateGameColorToString(selectedColor!, context)),
),
Transform.rotate(
angle: pi / 2,
child: const Icon(Icons.arrow_forward_ios, size: 16),
),
],
),
);
}
}

View File

@@ -28,10 +28,13 @@ class CreateMatchView extends StatefulWidget {
this.onWinnerChanged,
this.matchToEdit,
this.onMatchUpdated,
this.onMatchesUpdated,
});
final VoidCallback? onWinnerChanged;
final VoidCallback? onMatchesUpdated;
final void Function(Match)? onMatchUpdated;
/// An optional match to prefill the fields for editing.
@@ -115,6 +118,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Match name input field.
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
@@ -123,34 +127,40 @@ class _CreateMatchViewState extends State<CreateMatchView> {
maxLength: Constants.MAX_MATCH_NAME_LENGTH,
),
),
ChooseTile(
title: loc.game,
trailingText: selectedGame == null
? loc.none_group
: selectedGame!.name,
onPressed: () async {
selectedGame = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGameView(
games: gamesList,
initialGameId: selectedGame?.id ?? '',
// Game selection tile.
if (!isEditMode())
ChooseTile(
title: loc.game,
trailing: selectedGame == null
? Text(loc.none_group)
: Text(selectedGame!.name),
onPressed: () async {
selectedGame = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGameView(
games: gamesList,
initialGameId: selectedGame?.id ?? '',
onGamesUpdated: widget.onMatchesUpdated,
),
),
),
);
setState(() {
if (selectedGame != null) {
hintText = selectedGame!.name;
} else {
hintText = loc.match_name;
}
});
},
),
);
setState(() {
if (selectedGame != null) {
hintText = selectedGame!.name;
} else {
hintText = loc.match_name;
}
});
},
),
// Group selection tile.
ChooseTile(
title: loc.group,
trailingText: selectedGroup == null
? loc.none_group
: selectedGroup!.name,
trailing: selectedGroup == null
? Text(loc.none_group)
: Text(selectedGroup!.name),
onPressed: () async {
// Remove all players from the previously selected group from
// the selected players list, in case the user deselects the
@@ -181,6 +191,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
});
},
),
// Player selection widget.
Expanded(
child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'),
@@ -193,6 +205,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
},
),
),
// Create or save button.
CustomWidthButton(
text: buttonText,
sizeRelativeToWidth: 0.95,
@@ -218,16 +232,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 +266,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 +275,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,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:fluttericon/rpg_awesome_icons.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
@@ -14,6 +15,7 @@ import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/colored_icon_container.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/game_label.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -102,6 +104,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
bottom: 100,
),
children: [
// Controller Icon
const Center(
child: ColoredIconContainer(
icon: Icons.sports_esports,
@@ -110,6 +113,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
),
const SizedBox(height: 10),
// Match Name
Text(
match.name,
style: const TextStyle(
@@ -120,6 +125,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
textAlign: TextAlign.center,
),
const SizedBox(height: 5),
// Creation Date
Text(
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(match.createdAt)}',
style: const TextStyle(
@@ -129,6 +136,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
// Group Name
if (match.group != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -143,6 +152,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
const SizedBox(height: 20),
],
// Players
InfoTile(
title: loc.players,
icon: Icons.people,
@@ -162,6 +173,30 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
),
const SizedBox(height: 15),
// Game
InfoTile(
title: loc.game,
icon: RpgAwesome.clovers_card,
horizontalAlignment: CrossAxisAlignment.start,
content: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: GameLabel(
title: match.game.name,
description: translateRulesetToString(
match.game.ruleset,
context,
),
color: match.game.color,
),
),
),
const SizedBox(height: 15),
// Results
InfoTile(
title: loc.results,
icon: Icons.emoji_events,

View File

@@ -126,8 +126,10 @@ class _MatchViewState extends State<MatchView> {
Navigator.push(
context,
adaptivePageRoute(
builder: (context) =>
CreateMatchView(onWinnerChanged: loadMatches),
builder: (context) => CreateMatchView(
onWinnerChanged: loadMatches,
onMatchesUpdated: loadMatches,
),
),
);
},

View File

@@ -55,6 +55,7 @@ const allDependencies = <Package>[
_flutter_lints,
_flutter_localizations,
_flutter_plugin_android_lifecycle,
_flutter_popup,
_flutter_test,
_flutter_web_plugins,
_fluttericon,
@@ -168,6 +169,7 @@ const dependencies = <Package>[
_file_saver,
_flutter,
_flutter_localizations,
_flutter_popup,
_fluttericon,
_font_awesome_flutter,
_intl,
@@ -2628,6 +2630,41 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// flutter_popup 3.3.9
const _flutter_popup = Package(
name: 'flutter_popup',
description: 'The flutter_popup package is a versatile tool for creating customizable popups in Flutter apps. Its highlight feature effectively guides user attention to specific areas',
homepage: 'https://github.com/herowws/flutter_popup',
authors: [],
version: '3.3.9',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('flutter')],
devDependencies: [PackageRef('flutter_lints'), PackageRef('flutter_test')],
license: '''MIT License
Copyright (c) 2023 mopriestt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// flutter_test null
const _flutter_test = Package(
name: 'flutter_test',
@@ -37676,16 +37713,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// tallee 0.0.27+261
/// tallee 0.0.28+262
const _tallee = Package(
name: 'tallee',
description: 'Tracking App for Card Games',
authors: [],
version: '0.0.27+261',
version: '0.0.28+262',
spdxIdentifiers: ['LGPL-3.0'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('clock'), PackageRef('collection'), PackageRef('cupertino_icons'), PackageRef('drift'), PackageRef('drift_flutter'), PackageRef('file_picker'), PackageRef('file_saver'), PackageRef('flutter'), PackageRef('flutter_localizations'), PackageRef('fluttericon'), PackageRef('font_awesome_flutter'), PackageRef('intl'), PackageRef('json_schema'), PackageRef('package_info_plus'), PackageRef('path_provider'), PackageRef('provider'), PackageRef('skeletonizer'), PackageRef('url_launcher'), PackageRef('uuid')],
dependencies: [PackageRef('clock'), PackageRef('collection'), PackageRef('cupertino_icons'), PackageRef('drift'), PackageRef('drift_flutter'), PackageRef('file_picker'), PackageRef('file_saver'), PackageRef('flutter'), PackageRef('flutter_localizations'), PackageRef('flutter_popup'), PackageRef('fluttericon'), PackageRef('font_awesome_flutter'), PackageRef('intl'), PackageRef('json_schema'), PackageRef('package_info_plus'), PackageRef('path_provider'), PackageRef('provider'), PackageRef('skeletonizer'), PackageRef('url_launcher'), PackageRef('uuid')],
devDependencies: [PackageRef('flutter_test'), PackageRef('build_runner'), PackageRef('dart_pubspec_licenses'), PackageRef('drift_dev'), PackageRef('flutter_lints')],
license: '''GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007

View File

@@ -14,6 +14,7 @@ class AnimatedDialogButton extends StatefulWidget {
required this.onPressed,
this.buttonConstraints,
this.buttonType = ButtonType.primary,
this.isDescructive = false,
});
final String buttonText;
@@ -24,6 +25,8 @@ class AnimatedDialogButton extends StatefulWidget {
final ButtonType buttonType;
final bool isDescructive;
@override
State<AnimatedDialogButton> createState() => _AnimatedDialogButtonState();
}
@@ -33,28 +36,8 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
@override
Widget build(BuildContext context) {
final textStyling = TextStyle(
color: widget.buttonType == ButtonType.primary
? Colors.black
: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
);
final buttonDecoration = widget.buttonType == ButtonType.primary
// Primary
? BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
)
: widget.buttonType == ButtonType.secondary
// Secondary
? BoxDecoration(
border: BoxBorder.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(12),
)
// Tertiary
: const BoxDecoration();
final textStyling = _getTextStyling();
final buttonDecoration = _getButtonDecoration();
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
@@ -84,4 +67,42 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
),
);
}
TextStyle _getTextStyling() {
late Color textColor;
if (widget.buttonType == ButtonType.primary) {
textColor = widget.isDescructive ? Colors.white : Colors.black;
} else if (widget.buttonType == ButtonType.secondary) {
textColor = widget.isDescructive ? Colors.red : Colors.white;
} else {
textColor = widget.isDescructive ? Colors.red : Colors.white;
}
return TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
);
}
BoxDecoration _getButtonDecoration() {
if (widget.buttonType == ButtonType.primary) {
// Primary
return BoxDecoration(
color: widget.isDescructive ? Colors.red : Colors.white,
borderRadius: BorderRadius.circular(12),
);
} else if (widget.buttonType == ButtonType.secondary) {
// Secondary
return BoxDecoration(
border: BoxBorder.all(
color: widget.isDescructive ? Colors.red : Colors.white,
width: 2,
),
borderRadius: BorderRadius.circular(12),
);
}
// Tertiary
return const BoxDecoration();
}
}

View File

@@ -12,6 +12,7 @@ class CustomDialogAction extends StatelessWidget {
required this.onPressed,
required this.text,
this.buttonType = ButtonType.primary,
this.isDestructive = false,
});
final String text;
@@ -20,12 +21,15 @@ class CustomDialogAction extends StatelessWidget {
final VoidCallback onPressed;
final bool isDestructive;
@override
Widget build(BuildContext context) {
return AnimatedDialogButton(
onPressed: onPressed,
buttonText: text,
buttonType: buttonType,
isDescructive: isDestructive,
buttonConstraints: const BoxConstraints(minWidth: 300),
);
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/enums.dart';
class GameLabel extends StatelessWidget {
const GameLabel({
super.key,
required this.title,
required this.description,
required this.color,
});
final String title;
final String description;
final GameColor color;
@override
Widget build(BuildContext context) {
final backgroundColor = getColorFromGameColor(color);
final fontColor = backgroundColor.computeLuminance() > 0.5
? Colors.black
: Colors.white;
return IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Title
Container(
decoration: BoxDecoration(
color: backgroundColor.withAlpha(230),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
title,
style: TextStyle(
fontSize: 12,
color: fontColor,
fontWeight: FontWeight.bold,
),
),
),
// Description
Container(
decoration: BoxDecoration(
color: backgroundColor.withAlpha(140),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
description,
style: TextStyle(
fontSize: 12,
color: fontColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

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

@@ -4,12 +4,12 @@ import 'package:tallee/core/custom_theme.dart';
class ChooseTile extends StatefulWidget {
/// A tile widget that allows users to choose an option by tapping on it.
/// - [title]: The title text displayed on the tile.
/// - [trailingText]: Optional trailing text displayed on the tile.
/// - [trailing]: Optional trailing text displayed on the tile.
/// - [onPressed]: The callback invoked when the tile is tapped.
const ChooseTile({
super.key,
required this.title,
this.trailingText,
this.trailing,
this.onPressed,
});
@@ -20,7 +20,7 @@ class ChooseTile extends StatefulWidget {
final VoidCallback? onPressed;
/// Optional trailing text displayed on the tile.
final String? trailingText;
final Widget? trailing;
@override
State<ChooseTile> createState() => _ChooseTileState();
@@ -42,9 +42,11 @@ class _ChooseTileState extends State<ChooseTile> {
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),
if (widget.trailing != null) widget.trailing!,
if (widget.onPressed != null) ...[
const SizedBox(width: 10),
const Icon(Icons.arrow_forward_ios, size: 16),
],
],
),
),

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
class GameTile 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.
/// - [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.
const GameTile({
super.key,
required this.title,
required this.description,
this.onTap,
this.onLongPress,
this.isHighlighted = false,
this.badgeText,
this.badgeColor,
});
/// The title text displayed on the tile.
final String title;
/// The description text displayed below the title.
final String description;
/// The callback invoked when the tile is tapped.
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;
/// Optional text to display in a badge on the right side of the title.
final String? badgeText;
/// Optional color for the badge background.
final Color? badgeColor;
@override
Widget build(BuildContext context) {
final badgeTextColor = badgeColor != null
? (badgeColor!.computeLuminance() > 0.5 ? Colors.black : Colors.white)
: Colors.white;
final gameColor = badgeColor ?? getColorFromGameColor(GameColor.orange);
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: AnimatedContainer(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
decoration: !isHighlighted
? CustomTheme.standardBoxDecoration
: CustomTheme.highlightedBoxDecoration.copyWith(
border: Border.all(
color: gameColor.withValues(alpha: 0.9),
width: 2,
),
),
duration: const Duration(milliseconds: 200),
child: Stack(
children: [
// Gradient overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
gameColor.withValues(alpha: 0.08),
gameColor.withValues(alpha: 0.02),
Colors.transparent,
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Title
Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
// Badge
if (badgeText != null) ...[
const SizedBox(height: 5),
Container(
constraints: const BoxConstraints(maxWidth: 250),
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 6,
),
decoration: BoxDecoration(
color: gameColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
badgeText!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: TextStyle(
color: badgeTextColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
// Description
if (description.isNotEmpty) ...[
const SizedBox(height: 10),
Text(description, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 2.5),
],
],
),
),
],
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/game_label.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class MatchTile extends StatefulWidget {
@@ -116,56 +117,13 @@ class _MatchTileState extends State<MatchTile> {
// Game + Ruleset Badge
if (!widget.compact)
IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Game
Container(
decoration: BoxDecoration(
color: CustomTheme.primaryColor.withAlpha(230),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Text(
match.game.name,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
// Ruleset
Container(
decoration: BoxDecoration(
color: CustomTheme.primaryColor.withAlpha(140),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Text(
translateRulesetToString(match.game.ruleset, context),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
GameLabel(
title: match.game.name,
description: translateRulesetToString(
match.game.ruleset,
context,
),
color: match.game.color,
),
const SizedBox(height: 12),

View File

@@ -2,21 +2,17 @@ import 'package:flutter/material.dart';
import 'package:tallee/core/custom_theme.dart';
class TitleDescriptionListTile extends StatelessWidget {
/// A list tile widget that displays a title and description, with optional highlighting and badge.
/// A list tile widget that displays a title and description
/// - [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.
/// - [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.
const TitleDescriptionListTile({
super.key,
required this.title,
required this.description,
this.onPressed,
this.onTap,
this.isHighlighted = false,
this.badgeText,
this.badgeColor,
});
/// The title text displayed on the tile.
@@ -26,21 +22,15 @@ class TitleDescriptionListTile extends StatelessWidget {
final String description;
/// The callback invoked when the tile is tapped.
final VoidCallback? onPressed;
final VoidCallback? onTap;
/// A boolean to determine if the tile should be highlighted.
final bool isHighlighted;
/// Optional text to display in a badge on the right side of the title.
final String? badgeText;
/// Optional color for the badge background.
final Color? badgeColor;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
onTap: onTap,
child: AnimatedContainer(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
@@ -51,53 +41,26 @@ class TitleDescriptionListTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 230,
child: Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
// Title
SizedBox(
width: 230,
child: Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
if (badgeText != null) ...[
const Spacer(),
Container(
constraints: const BoxConstraints(maxWidth: 115),
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 6,
),
decoration: BoxDecoration(
color: badgeColor ?? CustomTheme.primaryColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
badgeText!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
// Description
if (description.isNotEmpty) ...[
const SizedBox(height: 5),
const SizedBox(height: 10),
Text(description, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 2.5),
],

View File

@@ -1,7 +1,7 @@
name: tallee
description: "Tracking App for Card Games"
publish_to: 'none'
version: 0.0.27+261
version: 0.0.28+262
environment:
sdk: ^3.8.1
@@ -19,6 +19,7 @@ dependencies:
flutter_localizations:
sdk: flutter
flutter_numeric_text: ^1.3.3
flutter_popup: ^3.3.9
fluttericon: ^2.0.0
font_awesome_flutter: ^11.0.0
intl: any

View File

@@ -241,15 +241,15 @@ void main() {
expect(matchExists, isTrue);
});
test('getGroupMatches() works correctly', () async {
var matches = await database.matchDao.getGroupMatches(
test('getMatchesByGroup() works correctly', () async {
var matches = await database.matchDao.getMatchesByGroup(
groupId: 'non-existing-id',
);
expect(matches, isEmpty);
await database.matchDao.addMatch(match: testMatch1);
matches = await database.matchDao.getGroupMatches(
matches = await database.matchDao.getMatchesByGroup(
groupId: testGroup1.id,
);
expect(matches, isNotEmpty);
@@ -259,6 +259,69 @@ void main() {
expect(match.group, isNotNull);
expect(match.group!.id, testGroup1.id);
});
test('getMatchCount() works correctly', () async {
var count = await database.matchDao.getMatchCount();
expect(count, 0);
await database.matchDao.addMatch(match: testMatch1);
count = await database.matchDao.getMatchCount();
expect(count, 1);
await database.matchDao.addMatch(match: testMatch2);
count = await database.matchDao.getMatchCount();
expect(count, 2);
await database.matchDao.deleteMatch(matchId: testMatch1.id);
count = await database.matchDao.getMatchCount();
expect(count, 1);
await database.matchDao.deleteMatch(matchId: testMatch2.id);
count = await database.matchDao.getMatchCount();
expect(count, 0);
});
test('getMatchCountByGame() works correctly', () async {
var count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 0);
await database.matchDao.addMatch(match: testMatch1);
count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 1);
await database.matchDao.addMatch(match: testMatch2);
count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 2);
await database.matchDao.deleteMatch(matchId: testMatch1.id);
count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 1);
await database.matchDao.deleteMatch(matchId: testMatch2.id);
count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 0);
});
test('getMatchCountByGame() returns 0 for non-existent game', () async {
final count = await database.matchDao.getMatchCountByGame(
gameId: 'non-existent-game-id',
);
expect(count, 0);
});
});
group('UPDATE', () {
@@ -386,7 +449,6 @@ void main() {
await database.matchDao.addMatch(match: testMatch1);
DateTime newEndedAt = DateTime(2030, 1, 1, 12, 0, 0);
print(newEndedAt);
await database.matchDao.updateMatchEndedAt(
matchId: testMatch1.id,
endedAt: newEndedAt,
@@ -408,31 +470,6 @@ void main() {
final allMatches = await database.matchDao.getAllMatches();
expect(allMatches, isEmpty);
});
test('Getting the match count works correctly', () async {
var matchCount = await database.matchDao.getMatchCount();
expect(matchCount, 0);
await database.matchDao.addMatch(match: testMatch1);
matchCount = await database.matchDao.getMatchCount();
expect(matchCount, 1);
await database.matchDao.addMatch(match: testMatch2);
matchCount = await database.matchDao.getMatchCount();
expect(matchCount, 2);
await database.matchDao.deleteMatch(matchId: testMatch1.id);
matchCount = await database.matchDao.getMatchCount();
expect(matchCount, 1);
await database.matchDao.deleteMatch(matchId: testMatch2.id);
matchCount = await database.matchDao.getMatchCount();
expect(matchCount, 0);
});
});
group('DELETE', () {
@@ -471,5 +508,33 @@ void main() {
expect(deleted, isFalse);
});
});
test('deleteMatchesByGame() deletes all matches for a game', () async {
await database.matchDao.addMatch(match: testMatch1);
await database.matchDao.addMatch(match: testMatch2);
var count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 2);
final deletedCount = await database.matchDao.deleteMatchesByGame(
gameId: testGame.id,
);
expect(deletedCount, 2);
count = await database.matchDao.getMatchCountByGame(gameId: testGame.id);
expect(count, 0);
final allMatches = await database.matchDao.getAllMatches();
expect(allMatches, isEmpty);
});
test('deleteMatchesByGame() returns 0 for non-existent game', () async {
final deletedCount = await database.matchDao.deleteMatchesByGame(
gameId: 'non-existent-game-id',
);
expect(deletedCount, 0);
});
});
}