diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 74d541d..9226c46 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -22,9 +22,15 @@ "days_ago": "vor {count} Tagen", "delete": "Löschen", "delete_all_data": "Alle Daten löschen", - "delete_group": "Gruppe löschen", + "delete_group": "Diese Gruppe löschen", "edit_group": "Gruppe bearbeiten", + "delete_group": "Gruppe löschen", + "delete_match": "Spiel löschen", + "edit_group": "Gruppe bearbeiten", + "enter_results": "Ergebnisse eintragen", "error_creating_group": "Fehler beim Erstellen der Gruppe, 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", "export_canceled": "Export abgebrochen", "export_data": "Daten exportieren", @@ -46,6 +52,7 @@ "licenses": "Lizenzen", "match_in_progress": "Spiel läuft...", "match_name": "Spieltitel", + "match_profile": "Spielprofil", "matches": "Spiele", "members": "Mitglieder", "most_points": "Höchste Punkte", @@ -58,6 +65,7 @@ "no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden", "no_players_selected": "Keine Spieler:innen ausgewählt", "no_recent_matches_available": "Keine letzten Spiele verfügbar", + "no_results_entered_yet": "Noch keine Ergebnisse eingetragen", "no_second_match_available": "Kein zweites Spiel verfügbar", "no_statistics_available": "Keine Statistiken verfügbar", "none": "Kein", @@ -70,11 +78,14 @@ "privacy_policy": "Datenschutzerklärung", "quick_create": "Schnellzugriff", "recent_matches": "Letzte Spiele", + "result": "Ergebnis", + "results": "Ergebnisse", "ruleset": "Regelwerk", "ruleset_least_points": "Umgekehrte Wertung: Der/die Spieler:in mit den wenigsten Punkten gewinnt.", "ruleset_most_points": "Traditionelles Regelwerk: Der/die Spieler:in mit den meisten Punkten gewinnt.", "ruleset_single_loser": "Genau ein:e Verlierer:in wird bestimmt; der letzte Platz erhält die Strafe oder Konsequenz.", "ruleset_single_winner": "Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.", + "save_changes": "Änderungen speichern", "search_for_groups": "Nach Gruppen suchen", "search_for_players": "Nach Spieler:innen suchen", "select_winner": "Gewinner:in wählen:", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 667c5c5..8c89e17 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -37,10 +37,10 @@ "description": "Button text to create a match" }, "@create_new_group": { - "description": "Button text to create a new group" + "description": "Appbar text to create a new group" }, "@create_new_match": { - "description": "Button text to create a new match" + "description": "Appbar text to create a new match" }, "@created_on": { "description": "Label for creation date" @@ -72,14 +72,26 @@ "description": "Confirmation dialog for deleting all data" }, "@delete_group": { - "description": "Button text to delete a group" + "description": "Confirmation dialog for deleting a group" + }, + "@delete_match": { + "description": "Button text to delete a match" }, "@edit_group": { - "description": "Button text to edit a group" + "description": "Button & Appbar label for editing a group" + }, + "@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" }, @@ -143,6 +155,9 @@ "@match_name": { "description": "Placeholder for match name input" }, + "@match_profile": { + "description": "Title for match profile view" + }, "@matches": { "description": "Label for matches" }, @@ -179,6 +194,9 @@ "@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" }, @@ -220,6 +238,9 @@ "@recent_matches": { "description": "Title for recent matches section" }, + "@results": { + "description": "Label for match results" + }, "@ruleset": { "description": "Ruleset label" }, @@ -235,6 +256,9 @@ "@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" }, @@ -321,8 +345,12 @@ "delete": "Delete", "delete_all_data": "Delete all data", "delete_group": "Delete Group", + "delete_match": "Delete Match", "edit_group": "Edit Group", + "enter_results": "Enter Results", "error_creating_group": "Error while creating group, 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", "export_canceled": "Export canceled", "export_data": "Export data", @@ -344,6 +372,7 @@ "licenses": "Licenses", "match_in_progress": "Match in progress...", "match_name": "Match name", + "match_profile": "Match Profile", "matches": "Matches", "members": "Members", "most_points": "Most Points", @@ -356,6 +385,7 @@ "no_players_found_with_that_name": "No players found with that name", "no_players_selected": "No players selected", "no_recent_matches_available": "No recent matches available", + "no_results_entered_yet": "No results entered yet", "no_second_match_available": "No second match available", "no_statistics_available": "No statistics available", "none": "None", @@ -368,11 +398,13 @@ "privacy_policy": "Privacy Policy", "quick_create": "Quick Create", "recent_matches": "Recent Matches", + "results": "Results", "ruleset": "Ruleset", "ruleset_least_points": "Inverse scoring: the player with the fewest points wins.", "ruleset_most_points": "Traditional ruleset: the player with the most points wins.", "ruleset_single_loser": "Exactly one loser is determined; last place receives the penalty or consequence.", "ruleset_single_winner": "Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.", + "save_changes": "Save Changes", "search_for_groups": "Search for groups", "search_for_players": "Search for players", "select_winner": "Select Winner:", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index b2a60c0..de5b43f 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -170,7 +170,7 @@ abstract class AppLocalizations { /// **'Create match'** String get create_match; - /// Button text to create a new group + /// Appbar text to create a new group /// /// In en, this message translates to: /// **'Create new group'** @@ -182,7 +182,7 @@ abstract class AppLocalizations { /// **'Created on'** String get created_on; - /// Button text to create a new match + /// Appbar text to create a new match /// /// In en, this message translates to: /// **'Create new match'** @@ -230,24 +230,48 @@ abstract class AppLocalizations { /// **'Delete all data'** String get delete_all_data; - /// Button text to delete a group + /// Confirmation dialog for deleting a group /// /// In en, this message translates to: /// **'Delete Group'** String get delete_group; - /// Button text to edit a group + /// Button text to delete a match + /// + /// In en, this message translates to: + /// **'Delete Match'** + String get delete_match; + + /// Button & Appbar label for editing a group /// /// In en, this message translates to: /// **'Edit Group'** String get edit_group; + /// Button text to enter match results + /// + /// In en, this message translates to: + /// **'Enter Results'** + String get enter_results; + /// Error message when group creation fails /// /// In en, this message translates to: /// **'Error while creating group, please try again'** String get error_creating_group; + /// Error message when group deletion fails + /// + /// In en, this message translates to: + /// **'Error while deleting group, please try again'** + String get error_deleting_group; + + /// Error message when group editing fails + /// + /// 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 /// /// In en, this message translates to: @@ -374,6 +398,12 @@ abstract class AppLocalizations { /// **'Match name'** String get match_name; + /// Title for match profile view + /// + /// In en, this message translates to: + /// **'Match Profile'** + String get match_profile; + /// Label for matches /// /// In en, this message translates to: @@ -446,6 +476,12 @@ abstract class AppLocalizations { /// **'No recent matches available'** String get no_recent_matches_available; + /// Message when no results have been entered yet + /// + /// In en, this message translates to: + /// **'No results entered yet'** + String get no_results_entered_yet; + /// Message when no second match exists /// /// In en, this message translates to: @@ -518,6 +554,12 @@ abstract class AppLocalizations { /// **'Recent Matches'** String get recent_matches; + /// Label for match results + /// + /// In en, this message translates to: + /// **'Results'** + String get results; + /// Ruleset label /// /// In en, this message translates to: @@ -548,6 +590,12 @@ abstract class AppLocalizations { /// **'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'** String get ruleset_single_winner; + /// Save changes button text + /// + /// In en, this message translates to: + /// **'Save Changes'** + String get save_changes; + /// Hint text for group search input field /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 2f76fd2..ad4b7ea 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -81,13 +81,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get delete_group => 'Gruppe löschen'; + @override + String get delete_match => 'Spiel löschen'; + @override String get edit_group => 'Gruppe bearbeiten'; + @override + String get enter_results => 'Ergebnisse eintragen'; + @override String get error_creating_group => 'Fehler beim Erstellen der Gruppe, bitte erneut versuchen'; + @override + String get error_deleting_group => + 'Fehler beim Löschen der Gruppe, bitte erneut versuchen'; + + @override + String get error_editing_group => + 'Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen'; + @override String get error_reading_file => 'Fehler beim Lesen der Datei'; @@ -151,6 +165,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get match_name => 'Spieltitel'; + @override + String get match_profile => 'Spielprofil'; + @override String get matches => 'Spiele'; @@ -188,6 +205,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get no_recent_matches_available => 'Keine letzten Spiele verfügbar'; + @override + String get no_results_entered_yet => 'Noch keine Ergebnisse eingetragen'; + @override String get no_second_match_available => 'Kein zweites Spiel verfügbar'; @@ -226,6 +246,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recent_matches => 'Letzte Spiele'; + @override + String get results => 'Ergebnisse'; + @override String get ruleset => 'Regelwerk'; @@ -245,6 +268,9 @@ class AppLocalizationsDe extends AppLocalizations { String get ruleset_single_winner => 'Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.'; + @override + String get save_changes => 'Änderungen speichern'; + @override String get search_for_groups => 'Nach Gruppen suchen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index cfcae20..29f1254 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -81,13 +81,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get delete_group => 'Delete Group'; + @override + String get delete_match => 'Delete Match'; + @override String get edit_group => 'Edit Group'; + @override + String get enter_results => 'Enter Results'; + @override String get error_creating_group => 'Error while creating group, please try again'; + @override + String get error_deleting_group => + 'Error while deleting group, please try again'; + + @override + String get error_editing_group => + 'Error while editing group, please try again'; + @override String get error_reading_file => 'Error reading file'; @@ -151,6 +165,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get match_name => 'Match name'; + @override + String get match_profile => 'Match Profile'; + @override String get matches => 'Matches'; @@ -188,6 +205,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get no_recent_matches_available => 'No recent matches available'; + @override + String get no_results_entered_yet => 'No results entered yet'; + @override String get no_second_match_available => 'No second match available'; @@ -226,6 +246,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recent_matches => 'Recent Matches'; + @override + String get results => 'Results'; + @override String get ruleset => 'Ruleset'; @@ -245,6 +268,9 @@ class AppLocalizationsEn extends AppLocalizations { String get ruleset_single_winner => 'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'; + @override + String get save_changes => 'Save Changes'; + @override String get search_for_groups => 'Search for groups'; diff --git a/lib/main.dart b/lib/main.dart index 0002531..ab0f34d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,10 +40,22 @@ class GameTracker extends StatelessWidget { primaryColor: CustomTheme.primaryColor, scaffoldBackgroundColor: CustomTheme.backgroundColor, appBarTheme: CustomTheme.appBarTheme, + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return CustomTheme.primaryColor; + } + return CustomTheme.textColor; + }), + ), colorScheme: ColorScheme.fromSeed( seedColor: CustomTheme.primaryColor, brightness: Brightness.dark, - ).copyWith(surface: CustomTheme.backgroundColor), + primary: CustomTheme.primaryColor, + onPrimary: CustomTheme.textColor, + surface: CustomTheme.backgroundColor, + onSurface: CustomTheme.textColor, + ), pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), diff --git a/lib/presentation/views/main_menu/custom_navigation_bar.dart b/lib/presentation/views/main_menu/custom_navigation_bar.dart index 6d18091..508463d 100644 --- a/lib/presentation/views/main_menu/custom_navigation_bar.dart +++ b/lib/presentation/views/main_menu/custom_navigation_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; -import 'package:tallee/presentation/views/main_menu/group_view/groups_view.dart'; +import 'package:tallee/presentation/views/main_menu/group_view/group_view.dart'; import 'package:tallee/presentation/views/main_menu/home_view.dart'; import 'package:tallee/presentation/views/main_menu/match_view/match_view.dart'; import 'package:tallee/presentation/views/main_menu/settings_view/settings_view.dart'; @@ -38,7 +38,7 @@ class _CustomNavigationBarState extends State ), KeyedSubtree( key: ValueKey('groups_$tabKeyCount'), - child: const GroupsView(), + child: const GroupView(), ), KeyedSubtree( key: ValueKey('stats_$tabKeyCount'), diff --git a/lib/presentation/views/main_menu/group_view/create_group_view.dart b/lib/presentation/views/main_menu/group_view/create_group_view.dart index 2b7ab86..be4ed70 100644 --- a/lib/presentation/views/main_menu/group_view/create_group_view.dart +++ b/lib/presentation/views/main_menu/group_view/create_group_view.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.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'; @@ -12,8 +11,10 @@ import 'package:tallee/presentation/widgets/player_selection.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; class CreateGroupView extends StatefulWidget { - /// A view that allows the user to create a new group - const CreateGroupView({super.key}); + const CreateGroupView({super.key, this.groupToEdit}); + + /// The group to edit, if any + final Group? groupToEdit; @override State createState() => _CreateGroupViewState(); @@ -22,16 +23,29 @@ class CreateGroupView extends StatefulWidget { class _CreateGroupViewState extends State { late final AppDatabase db; + /// GlobalKey for ScaffoldMessenger to show snackbars + final _scaffoldMessengerKey = GlobalKey(); + /// Controller for the group name input field final _groupNameController = TextEditingController(); /// List of currently selected players List selectedPlayers = []; + /// List of initially selected players (when editing a group) + List initialSelectedPlayers = []; + @override void initState() { super.initState(); db = Provider.of(context, listen: false); + if (widget.groupToEdit != null) { + _groupNameController.text = widget.groupToEdit!.name; + setState(() { + initialSelectedPlayers = widget.groupToEdit!.members; + selectedPlayers = widget.groupToEdit!.members; + }); + } _groupNameController.addListener(() { setState(() {}); }); @@ -47,10 +61,58 @@ class _CreateGroupViewState extends State { Widget build(BuildContext context) { final loc = AppLocalizations.of(context); return ScaffoldMessenger( + key: _scaffoldMessengerKey, child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: CustomTheme.backgroundColor, - appBar: AppBar(title: Text(loc.create_new_group)), + appBar: AppBar( + title: Text( + widget.groupToEdit == null ? loc.create_new_group : loc.edit_group, + ), + actions: widget.groupToEdit == null + ? [] + : [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + if (widget.groupToEdit != null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(loc.delete_group), + content: Text(loc.this_cannot_be_undone), + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(false), + child: Text(loc.cancel), + ), + TextButton( + onPressed: () => + Navigator.of(context).pop(true), + child: Text(loc.delete), + ), + ], + ), + ).then((confirmed) async { + if (confirmed == true && context.mounted) { + bool success = await db.groupDao.deleteGroup( + groupId: widget.groupToEdit!.id, + ); + if (!context.mounted) return; + if (success) { + Navigator.pop(context); + } else { + if (!mounted) return; + showSnackbar(message: loc.error_deleting_group); + } + } + }); + } + }, + ), + ], + ), body: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -60,11 +122,11 @@ class _CreateGroupViewState extends State { child: TextInputField( controller: _groupNameController, hintText: loc.group_name, - maxLength: Constants.MAX_GROUP_NAME_LENGTH, ), ), Expanded( child: PlayerSelection( + initialSelectedPlayers: initialSelectedPlayers, onChanged: (value) { setState(() { selectedPlayers = [...value]; @@ -73,7 +135,9 @@ class _CreateGroupViewState extends State { ), ), CustomWidthButton( - text: loc.create_group, + text: widget.groupToEdit == null + ? loc.create_group + : loc.edit_group, sizeRelativeToWidth: 0.95, buttonType: ButtonType.primary, onPressed: @@ -81,28 +145,37 @@ class _CreateGroupViewState extends State { (selectedPlayers.length < 2)) ? null : () async { - bool success = await db.groupDao.addGroup( - group: Group( + late Group? updatedGroup; + late bool success; + if (widget.groupToEdit == null) { + success = await db.groupDao.addGroup( + group: Group( + name: _groupNameController.text.trim(), + members: selectedPlayers, + ), + ); + } else { + updatedGroup = Group( + id: widget.groupToEdit!.id, name: _groupNameController.text.trim(), members: selectedPlayers, - ), - ); + ); + //TODO: Implement group editing in database + /* + success = await db.groupDao.updateGroup( + group: updatedGroup, + ); + */ + success = true; + } if (!context.mounted) return; if (success) { - Navigator.pop(context); + Navigator.pop(context, updatedGroup); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: CustomTheme.boxColor, - content: Center( - child: Text( - AppLocalizations.of( - context, - ).error_creating_group, - style: const TextStyle(color: Colors.white), - ), - ), - ), + showSnackbar( + message: widget.groupToEdit == null + ? loc.error_creating_group + : loc.error_editing_group, ); } }, @@ -114,4 +187,20 @@ class _CreateGroupViewState extends State { ), ); } + + /// Displays a snackbar with the given message and optional action. + /// + /// [message] The message to display in the snackbar. + void showSnackbar({required String message}) { + final messenger = _scaffoldMessengerKey.currentState; + if (messenger != null) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(message, style: const TextStyle(color: Colors.white)), + backgroundColor: CustomTheme.boxColor, + ), + ); + } + } } diff --git a/lib/presentation/views/main_menu/group_view/group_profile_view.dart b/lib/presentation/views/main_menu/group_view/group_detail_view.dart similarity index 86% rename from lib/presentation/views/main_menu/group_view/group_profile_view.dart rename to lib/presentation/views/main_menu/group_view/group_detail_view.dart index d4b71ab..ad88d66 100644 --- a/lib/presentation/views/main_menu/group_view/group_profile_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_detail_view.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/dto/group.dart'; import 'package:tallee/data/dto/match.dart'; import 'package:tallee/data/dto/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/group_view/create_group_view.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart'; import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; @@ -15,10 +17,10 @@ import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; -class GroupProfileView extends StatefulWidget { +class GroupDetailView extends StatefulWidget { /// A view that displays the profile of a group /// - [group]: The group to display - const GroupProfileView({ + const GroupDetailView({ super.key, required this.group, required this.callback, @@ -30,21 +32,24 @@ class GroupProfileView extends StatefulWidget { final VoidCallback callback; @override - State createState() => _GroupProfileViewState(); + State createState() => _GroupDetailViewState(); } -class _GroupProfileViewState extends State { +class _GroupDetailViewState extends State { late final AppDatabase db; bool isLoading = true; + late Group _group; /// Total matches played in this group int totalMatches = 0; + /// The best player in this group String bestPlayer = ''; @override void initState() { super.initState(); + _group = widget.group; db = Provider.of(context, listen: false); _loadStatistics(); } @@ -87,7 +92,7 @@ class _GroupProfileViewState extends State { ), ).then((confirmed) async { if (confirmed! && context.mounted) { - await db.groupDao.deleteGroup(groupId: widget.group.id); + await db.groupDao.deleteGroup(groupId: _group.id); if (!context.mounted) return; Navigator.pop(context); widget.callback.call(); @@ -118,7 +123,7 @@ class _GroupProfileViewState extends State { ), const SizedBox(height: 10), Text( - widget.group.name, + _group.name, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, @@ -128,7 +133,7 @@ class _GroupProfileViewState extends State { ), const SizedBox(height: 5), Text( - '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(widget.group.createdAt)}', + '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(_group.createdAt)}', style: const TextStyle( fontSize: 12, color: CustomTheme.textColor, @@ -145,7 +150,7 @@ class _GroupProfileViewState extends State { crossAxisAlignment: WrapCrossAlignment.start, spacing: 12, runSpacing: 8, - children: widget.group.members.map((member) { + children: _group.members.map((member) { return TextIconTile( text: member.name, iconEnabled: false, @@ -163,7 +168,7 @@ class _GroupProfileViewState extends State { children: [ _buildStatRow( loc.members, - widget.group.members.length.toString(), + _group.members.length.toString(), ), _buildStatRow( loc.played_matches, @@ -181,19 +186,22 @@ class _GroupProfileViewState extends State { child: MainMenuButton( text: loc.edit_group, icon: Icons.edit, - onPressed: () { - // TODO: Uncomment when GroupDetailView is implemented - /* - await Navigator.push( + onPressed: () async { + final updatedGroup = await Navigator.push( context, adaptivePageRoute( builder: (context) { - - return const GroupDetailView(); + return CreateGroupView(groupToEdit: _group); }, ), - );*/ - print('Edit Group pressed'); + ); + if (updatedGroup != null && mounted) { + setState(() { + _group = updatedGroup; + }); + _loadStatistics(); + widget.callback(); + } }, ), ), @@ -236,7 +244,7 @@ class _GroupProfileViewState extends State { Future _loadStatistics() async { final matches = await db.matchDao.getAllMatches(); final groupMatches = matches - .where((match) => match.group?.id == widget.group.id) + .where((match) => match.group?.id == _group.id) .toList(); setState(() { diff --git a/lib/presentation/views/main_menu/group_view/groups_view.dart b/lib/presentation/views/main_menu/group_view/group_view.dart similarity index 94% rename from lib/presentation/views/main_menu/group_view/groups_view.dart rename to lib/presentation/views/main_menu/group_view/group_view.dart index 6035fc8..92d4489 100644 --- a/lib/presentation/views/main_menu/group_view/groups_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_view.dart @@ -8,21 +8,21 @@ import 'package:tallee/data/dto/group.dart'; import 'package:tallee/data/dto/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/group_view/create_group_view.dart'; -import 'package:tallee/presentation/views/main_menu/group_view/group_profile_view.dart'; +import 'package:tallee/presentation/views/main_menu/group_view/group_detail_view.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/tiles/group_tile.dart'; import 'package:tallee/presentation/widgets/top_centered_message.dart'; -class GroupsView extends StatefulWidget { +class GroupView extends StatefulWidget { /// A view that displays a list of groups - const GroupsView({super.key}); + const GroupView({super.key}); @override - State createState() => _GroupsViewState(); + State createState() => _GroupViewState(); } -class _GroupsViewState extends State { +class _GroupViewState extends State { late final AppDatabase db; /// Loaded groups from the database @@ -82,7 +82,7 @@ class _GroupsViewState extends State { context, adaptivePageRoute( builder: (context) { - return GroupProfileView( + return GroupDetailView( group: groups[index], callback: loadGroups, ); diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart index 8182ddb..cf7ce86 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart @@ -20,11 +20,14 @@ import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; class CreateMatchView extends StatefulWidget { /// A view that allows creating a new match /// [onWinnerChanged]: Optional callback invoked when the winner is changed - const CreateMatchView({super.key, this.onWinnerChanged}); + const CreateMatchView({super.key, this.onWinnerChanged, this.match}); /// Optional callback invoked when the winner is changed final VoidCallback? onWinnerChanged; + /// An optional match to prefill the fields + final Match? match; + @override State createState() => _CreateMatchViewState(); } @@ -64,6 +67,9 @@ class _CreateMatchViewState extends State { /// The currently selected players List? selectedPlayers; + /// GlobalKey for ScaffoldMessenger to show snackbars + final _scaffoldMessengerKey = GlobalKey(); + @override void initState() { super.initState(); @@ -83,6 +89,20 @@ class _CreateMatchViewState extends State { filteredPlayerList = List.from(playerList); }); }); + + // If a match is provided, prefill the fields + if (widget.match != null) { + final match = widget.match!; + _matchNameController.text = match.name; + selectedGroup = match.group; + selectedGroupId = match.group?.id ?? ''; + selectedPlayers = match.players ?? []; + if (selectedGroup != null) { + filteredPlayerList = playerList + .where((p) => !selectedGroup!.members.any((m) => m.id == p.id)) + .toList(); + } + } } @override @@ -106,7 +126,12 @@ class _CreateMatchViewState extends State { @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); + final buttonText = widget.match != null + ? loc.save_changes + : loc.create_match; + return ScaffoldMessenger( + key: _scaffoldMessengerKey, child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: CustomTheme.backgroundColor, @@ -187,32 +212,12 @@ class _CreateMatchViewState extends State { ), ), CustomWidthButton( - text: loc.create_match, + text: buttonText, sizeRelativeToWidth: 0.95, buttonType: ButtonType.primary, onPressed: _enableCreateGameButton() - ? () async { - Match match = Match( - name: _matchNameController.text.isEmpty - ? (hintText ?? '') - : _matchNameController.text.trim(), - createdAt: DateTime.now(), - group: selectedGroup, - players: selectedPlayers, - ); - await db.matchDao.addMatch(match: match); - if (context.mounted) { - Navigator.pushReplacement( - context, - adaptivePageRoute( - fullscreenDialog: true, - builder: (context) => MatchResultView( - match: match, - onWinnerChanged: widget.onWinnerChanged, - ), - ), - ); - } + ? () { + buttonNavigation(context); } : null, ), @@ -232,4 +237,33 @@ class _CreateMatchViewState extends State { return (selectedGroup != null || (selectedPlayers != null && selectedPlayers!.length > 1)); } + + void buttonNavigation(BuildContext context) async { + if (widget.match != null) { + // TODO: Implement updating match logic here + Navigator.pop(context); + } else { + Match match = Match( + name: _matchNameController.text.isEmpty + ? (hintText ?? '') + : _matchNameController.text.trim(), + createdAt: DateTime.now(), + group: selectedGroup, + players: selectedPlayers, + ); + await db.matchDao.addMatch(match: match); + if (context.mounted) { + Navigator.pushReplacement( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => MatchResultView( + match: match, + onWinnerChanged: widget.onWinnerChanged, + ), + ), + ); + } + } + } } diff --git a/lib/presentation/views/main_menu/match_view/match_detail_view.dart b/lib/presentation/views/main_menu/match_view/match_detail_view.dart new file mode 100644 index 0000000..6336bd0 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/match_detail_view.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:tallee/core/adaptive_page_route.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/data/db/database.dart'; +import 'package:tallee/data/dto/match.dart'; +import 'package:tallee/data/dto/player.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_match_view.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; +import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; +import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; +import 'package:tallee/presentation/widgets/colored_icon_container.dart'; +import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; + +class MatchDetailView extends StatefulWidget { + /// A view that displays the profile of a match + /// - [match]: The match to display + /// - [callback]: Callback to refresh the match list + const MatchDetailView({ + super.key, + required this.match, + required this.callback, + }); + + /// The match to display + final Match match; + + /// Callback to refresh the match list + final VoidCallback callback; + + @override + State createState() => _MatchDetailViewState(); +} + +class _MatchDetailViewState extends State { + late final AppDatabase db; + + late Player? currentWinner; + + /// All players who participated in the match + late final List allPlayers; + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + allPlayers = _getAllPlayers(); + currentWinner = widget.match.winner; + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + title: Text(loc.match_profile), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + showDialog( + context: context, + builder: (context) => CustomAlertDialog( + title: '${loc.delete_match}?', + content: loc.this_cannot_be_undone, + actions: [ + AnimatedDialogButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + loc.cancel, + style: const TextStyle(color: CustomTheme.textColor), + ), + ), + AnimatedDialogButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + loc.delete, + style: const TextStyle( + color: CustomTheme.secondaryColor, + ), + ), + ), + ], + ), + ).then((confirmed) async { + if (confirmed! && context.mounted) { + await db.matchDao.deleteMatch(matchId: widget.match.id); + if (!context.mounted) return; + Navigator.pop(context); + widget.callback.call(); + } + }); + }, + ), + ], + ), + body: SafeArea( + child: Stack( + alignment: Alignment.center, + children: [ + ListView( + padding: const EdgeInsets.only( + left: 12, + right: 12, + top: 20, + bottom: 100, + ), + children: [ + const Center( + child: ColoredIconContainer( + icon: Icons.sports_esports, + containerSize: 55, + iconSize: 38, + ), + ), + const SizedBox(height: 10), + Text( + widget.match.name, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 5), + Text( + '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(widget.match.createdAt)}', + style: const TextStyle( + fontSize: 12, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + if (widget.match.group != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.group), + const SizedBox(width: 8), + Text( + // TODO: Update after DB changes + '${widget.match.group!.name} ${widget.match.players != null ? '+ ${widget.match.players!.length}' : ''}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 20), + ], + InfoTile( + title: loc.players, + icon: Icons.people, + horizontalAlignment: CrossAxisAlignment.start, + content: Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: allPlayers.map((player) { + return TextIconTile( + text: player.name, + iconEnabled: false, + ); + }).toList(), + ), + ), + const SizedBox(height: 15), + InfoTile( + title: loc.results, + icon: Icons.emoji_events, + content: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + /// TODO: Implement different ruleset results display + if (currentWinner != null) ...[ + Text( + loc.winner, + style: const TextStyle( + fontSize: 16, + color: CustomTheme.textColor, + ), + ), + Text( + currentWinner!.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: widget.match.winner != null + ? CustomTheme.primaryColor + : CustomTheme.textColor, + ), + ), + ] else ...[ + Text( + loc.no_results_entered_yet, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.textColor, + ), + ), + ], + ], + ), + ), + ), + ], + ), + Positioned( + bottom: MediaQuery.paddingOf(context).bottom, + child: Row( + children: [ + MainMenuButton( + icon: Icons.edit, + onPressed: () => Navigator.push( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => + CreateMatchView(match: widget.match), + ), + ), + ), + const SizedBox(width: 15), + MainMenuButton( + text: loc.enter_results, + icon: Icons.emoji_events, + onPressed: () async { + currentWinner = await Navigator.push( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => MatchResultView( + match: widget.match, + onWinnerChanged: () { + widget.callback.call(); + setState(() {}); + }, + ), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Gets all players who participated in the match (from group and individual players) + List _getAllPlayers() { + final List players = []; + + // Add group members if group exists + if (widget.match.group != null) { + players.addAll(widget.match.group!.members); + } + + // Add individual players + if (widget.match.players != null) { + for (var player in widget.match.players!) { + // Avoid duplicates + if (!players.any((p) => p.id == player.id)) { + players.add(player); + } + } + } + + return players; + } +} diff --git a/lib/presentation/views/main_menu/match_view/match_result_view.dart b/lib/presentation/views/main_menu/match_view/match_result_view.dart index 214c500..1dfb8cb 100644 --- a/lib/presentation/views/main_menu/match_view/match_result_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_result_view.dart @@ -54,7 +54,7 @@ class _MatchResultViewState extends State { icon: const Icon(Icons.close), onPressed: () { widget.onWinnerChanged?.call(); - Navigator.of(context).pop(); + Navigator.of(context).pop(_selectedPlayer); }, ), title: Text(widget.match.name), diff --git a/lib/presentation/views/main_menu/match_view/match_view.dart b/lib/presentation/views/main_menu/match_view/match_view.dart index c34a22f..c6abd2b 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -12,7 +12,7 @@ import 'package:tallee/data/dto/match.dart'; import 'package:tallee/data/dto/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_match_view.dart'; -import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/match_detail_view.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/tiles/match_tile.dart'; @@ -89,10 +89,9 @@ class _MatchViewState extends State { Navigator.push( context, adaptivePageRoute( - fullscreenDialog: true, - builder: (context) => MatchResultView( + builder: (context) => MatchDetailView( match: matches[index], - onWinnerChanged: loadGames, + callback: loadGames, ), ), ); @@ -128,6 +127,7 @@ class _MatchViewState extends State { /// Loads the games from the database and sorts them by creation date. void loadGames() { + isLoading = true; Future.wait([ db.matchDao.getAllMatches(), Future.delayed(Constants.MINIMUM_SKELETON_DURATION), diff --git a/lib/presentation/views/main_menu/settings_view/settings_view.dart b/lib/presentation/views/main_menu/settings_view/settings_view.dart index d063e7c..6a558ad 100644 --- a/lib/presentation/views/main_menu/settings_view/settings_view.dart +++ b/lib/presentation/views/main_menu/settings_view/settings_view.dart @@ -31,6 +31,7 @@ class _SettingsViewState extends State { version: 'n.A.', buildNumber: 'n.A.', ); + @override void initState() { super.initState(); diff --git a/lib/presentation/widgets/buttons/main_menu_button.dart b/lib/presentation/widgets/buttons/main_menu_button.dart index 747c31e..c583456 100644 --- a/lib/presentation/widgets/buttons/main_menu_button.dart +++ b/lib/presentation/widgets/buttons/main_menu_button.dart @@ -2,25 +2,25 @@ import 'package:flutter/material.dart'; class MainMenuButton extends StatefulWidget { /// A button for the main menu with an optional icon and a press animation. - /// - [text]: The text of the button. - /// - [icon]: The icon of the button. /// - [onPressed]: The callback to be invoked when the button is pressed. + /// - [icon]: The icon of the button. + /// - [text]: The text of the button. const MainMenuButton({ super.key, - required this.text, - this.icon, required this.onPressed, + required this.icon, + this.text, }); - /// The text of the button. - final String text; - - /// The icon of the button. - final IconData? icon; - /// The callback to be invoked when the button is pressed. final void Function() onPressed; + /// The icon of the button. + final IconData icon; + + /// The text of the button. + final String? text; + @override State createState() => _MainMenuButtonState(); } @@ -71,18 +71,18 @@ class _MainMenuButtonState extends State mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - if (widget.icon != null) ...[ - Icon(widget.icon, size: 26, color: Colors.black), + Icon(widget.icon, size: 26, color: Colors.black), + if (widget.text != null) ...[ const SizedBox(width: 7), - ], - Text( - widget.text, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.black, + Text( + widget.text!, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), + ], ], ), ), diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index bb3e3b9..5984841 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -70,6 +70,7 @@ class _PlayerSelectionState extends State { super.initState(); db = Provider.of(context, listen: false); suggestedPlayers = skeletonData; + selectedPlayers = widget.initialSelectedPlayers ?? []; loadPlayerList(); } @@ -100,7 +101,7 @@ class _PlayerSelectionState extends State { if (value.isEmpty) { // If the search is empty, it shows all unselected players. suggestedPlayers = allPlayers.where((player) { - return !selectedPlayers.contains(player); + return !selectedPlayers.any((p) => p.id == player.id); }).toList(); } else { // If there is input, it filters by name match (case-insensitive) and ensures @@ -109,9 +110,7 @@ class _PlayerSelectionState extends State { final bool nameMatches = player.name.toLowerCase().contains( value.toLowerCase(), ); - final bool isNotSelected = !selectedPlayers.contains( - player, - ); + final bool isNotSelected = !selectedPlayers.any((p) => p.id == player.id); return nameMatches && isNotSelected; }).toList(); } @@ -126,46 +125,49 @@ class _PlayerSelectionState extends State { const SizedBox(height: 10), SizedBox( height: 50, - child: selectedPlayers.isEmpty - ? Center(child: Text(loc.no_players_selected)) - : SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var player in selectedPlayers) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: TextIconTile( - text: player.name, - onIconTap: () { - setState(() { - // Removes the player from the selection and notifies the parent. - selectedPlayers.remove(player); - widget.onChanged([...selectedPlayers]); + child: AppSkeleton( + enabled: isLoading, + child: selectedPlayers.isEmpty + ? Center(child: Text(loc.no_players_selected)) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var player in selectedPlayers) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: TextIconTile( + text: player.name, + onIconTap: () { + setState(() { + // Removes the player from the selection and notifies the parent. + selectedPlayers.remove(player); + widget.onChanged([...selectedPlayers]); - // Get the current search query - final currentSearch = _searchBarController - .text - .toLowerCase(); + // Get the current search query + final currentSearch = _searchBarController + .text + .toLowerCase(); - // If the player matches the current search query (or search is empty), - // they are added back to the `suggestedPlayers` and the list is re-sorted. - if (currentSearch.isEmpty || - player.name.toLowerCase().contains( - currentSearch, - )) { - suggestedPlayers.add(player); - suggestedPlayers.sort( - (a, b) => a.name.compareTo(b.name), - ); - } - }); - }, + // If the player matches the current search query (or search is empty), + // they are added back to the `suggestedPlayers` and the list is re-sorted. + if (currentSearch.isEmpty || + player.name.toLowerCase().contains( + currentSearch, + )) { + suggestedPlayers.add(player); + suggestedPlayers.sort( + (a, b) => a.name.compareTo(b.name), + ); + } + }); + }, + ), ), - ), - ], + ], + ), ), - ), + ), ), const SizedBox(height: 10), Text( @@ -245,7 +247,21 @@ class _PlayerSelectionState extends State { // Otherwise, use the loaded players from the database. loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); allPlayers = [...loadedPlayers]; - suggestedPlayers = [...loadedPlayers]; + if (widget.initialSelectedPlayers != null) { + // Excludes already selected players from the suggested players list. + suggestedPlayers = loadedPlayers.where((p) => !widget.initialSelectedPlayers!.any((ip) => ip.id == p.id)).toList(); + // Ensures that only players available for selection are pre-selected. + selectedPlayers = widget.initialSelectedPlayers! + .where( + (p) => allPlayers.any( + (available) => available.id == p.id, + ), + ) + .toList(); + } else { + // If no initial selection, all loaded players are suggested. + suggestedPlayers = [...loadedPlayers]; + } } isLoading = false; }); diff --git a/lib/presentation/widgets/tiles/custom_radio_list_tile.dart b/lib/presentation/widgets/tiles/custom_radio_list_tile.dart index 53d0a03..5559d10 100644 --- a/lib/presentation/widgets/tiles/custom_radio_list_tile.dart +++ b/lib/presentation/widgets/tiles/custom_radio_list_tile.dart @@ -36,11 +36,7 @@ class CustomRadioListTile extends StatelessWidget { ), child: Row( children: [ - Radio( - value: value, - activeColor: CustomTheme.primaryColor, - toggleable: true, - ), + Radio(value: value, toggleable: true), Expanded( child: Text( text,