From e3aef81ab67dbb17df95a43064e0776c0704d81c Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sun, 3 May 2026 01:00:44 +0200 Subject: [PATCH] feat: Deleting games associated with matches deletes them --- lib/data/dao/match_dao.dart | 19 ++++ lib/l10n/arb/app_de.arb | 8 ++ lib/l10n/arb/app_en.arb | 8 ++ lib/l10n/generated/app_localizations.dart | 6 ++ lib/l10n/generated/app_localizations_de.dart | 11 +++ lib/l10n/generated/app_localizations_en.dart | 11 +++ .../create_match/choose_game_view.dart | 9 +- .../create_game/create_game_view.dart | 94 +++++++++++-------- .../buttons/animated_dialog_button.dart | 65 ++++++++----- .../widgets/dialog/custom_dialog_action.dart | 4 + test/db_tests/aggregates/match_test.dart | 50 ++++++++++ 11 files changed, 218 insertions(+), 67 deletions(-) diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 93df7d7..48098ee 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -299,6 +299,25 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { return count ?? 0; } + /// Retrieves the number of matches associated with a specific game. + Future 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; + } + + /// Deletes all matches associated with a specific game. + /// Returns the number of matches deleted. + Future deleteMatchesByGame({required String gameId}) async { + final query = delete(matchTable)..where((m) => m.gameId.equals(gameId)); + final rowsAffected = await query.go(); + return rowsAffected; + } + /// Retrieves all matches associated with the given [groupId]. /// Queries the database directly, filtering by [groupId]. Future> getGroupMatches({required String groupId}) async { diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index ba4fe38..e518525 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -34,6 +34,14 @@ "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, werden {count, plural, =1{1 Spiel} other{{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", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 98d1c38..c01f0b2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -389,6 +389,14 @@ "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", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 8e44e7b..790597f 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -302,6 +302,12 @@ abstract class AppLocalizations { /// **'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); + /// Confirmation dialog for deleting a group /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 3c2b4e3..2b20848 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -114,6 +114,17 @@ class AppLocalizationsDe extends AppLocalizations { @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: '$count Spiele', + one: '1 Spiel', + ); + return 'Wenn du diese Spielvorlage löschst, werden $_temp0 mit dieser Spielvorlage ebenfalls gelöscht.'; + } + @override String get delete_group => 'Gruppe löschen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e14b7a0..323d8c8 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -114,6 +114,17 @@ class AppLocalizationsEn extends AppLocalizations { @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'; diff --git a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart index fca65bb..ef92638 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart @@ -159,7 +159,7 @@ class _ChooseGameViewState extends State { adaptivePageRoute( builder: (context) => CreateGameView( gameToEdit: game, - canDelete: canDeleteGame(game), + matchCount: getMatchCount(game), onGameChanged: () { widget.onGamesUpdated?.call(); }, @@ -224,11 +224,10 @@ class _ChooseGameViewState extends State { gameCounts = await db.gameDao.getGameUsage(); } - // A game can only be deleted if there are no matches using it - bool canDeleteGame(Game game) { - final count = gameCounts + // 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; - return count == 0; } } diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart index ba4d101..52e6c14 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/create_game/create_game_view.dart @@ -25,7 +25,7 @@ class CreateGameView extends StatefulWidget { super.key, required this.onGameChanged, this.gameToEdit, - this.canDelete = false, + this.matchCount = 0, }); /// Callback to invoke when the game is created or edited @@ -34,7 +34,7 @@ class CreateGameView extends StatefulWidget { /// An optional game to prefill the fields final Game? gameToEdit; - final bool canDelete; + final int matchCount; @override State createState() => _CreateGameViewState(); @@ -139,45 +139,59 @@ class _CreateGameViewState extends State { if (isEditMode()) IconButton( icon: const Icon(Icons.delete), - onPressed: widget.canDelete - ? () async { - showDialog( - context: context, - builder: (context) => CustomAlertDialog( - title: loc.delete_game, - content: Text(loc.this_cannot_be_undone), - actions: [ - CustomDialogAction( - onPressed: () => - Navigator.of(context).pop(false), - text: loc.cancel, - ), - CustomDialogAction( - onPressed: () => - Navigator.of(context).pop(true), - text: loc.delete, - ), - ], - ), - ).then((confirmed) async { - if (confirmed == true && context.mounted) { - bool success = await db.gameDao.deleteGame( - gameId: widget.gameToEdit!.id, - ); - if (!context.mounted) return; - if (success) { - widget.onGameChanged.call(); - Navigator.of( - context, - ).pop((game: widget.gameToEdit, delete: true)); - } else { - if (!mounted) return; - showSnackbar(message: loc.error_deleting_game); - } - } - }); + 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( + context: context, + builder: (context) => CustomAlertDialog( + title: loc.delete_game, + content: Text(dialogContent), + 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, + ); } - : null, + + // 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); + } + } + }); + }, ), ], ), diff --git a/lib/presentation/widgets/buttons/animated_dialog_button.dart b/lib/presentation/widgets/buttons/animated_dialog_button.dart index 70deea6..8c8765e 100644 --- a/lib/presentation/widgets/buttons/animated_dialog_button.dart +++ b/lib/presentation/widgets/buttons/animated_dialog_button.dart @@ -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 createState() => _AnimatedDialogButtonState(); } @@ -33,28 +36,8 @@ class _AnimatedDialogButtonState extends State { @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 { ), ); } + + 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(); + } } diff --git a/lib/presentation/widgets/dialog/custom_dialog_action.dart b/lib/presentation/widgets/dialog/custom_dialog_action.dart index aec0dfa..47024dc 100644 --- a/lib/presentation/widgets/dialog/custom_dialog_action.dart +++ b/lib/presentation/widgets/dialog/custom_dialog_action.dart @@ -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), ); } diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index 3305b9a..9ba33ac 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -366,5 +366,55 @@ void main() { expect(match.group, isNotNull); expect(match.group!.id, testGroup1.id); }); + + 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); + }); + + test('getMatchCountByGame() returns 0 for non-existent game', () async { + final count = await database.matchDao.getMatchCountByGame( + gameId: 'non-existent-game-id', + ); + expect(count, 0); + }); + + 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); + }); }); }