feat: Deleting games associated with matches deletes them
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 42s
Pull Request Pipeline / lint (pull_request) Successful in 50s

This commit is contained in:
2026-05-03 01:00:44 +02:00
parent 92bf74683f
commit e3aef81ab6
11 changed files with 218 additions and 67 deletions

View File

@@ -299,6 +299,25 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
return count ?? 0;
}
/// 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;
}
/// 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;
}
/// Retrieves all matches associated with the given [groupId].
/// Queries the database directly, filtering by [groupId].
Future<List<Match>> getGroupMatches({required String groupId}) async {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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';

View File

@@ -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';

View File

@@ -159,7 +159,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
adaptivePageRoute(
builder: (context) => CreateGameView(
gameToEdit: game,
canDelete: canDeleteGame(game),
matchCount: getMatchCount(game),
onGameChanged: () {
widget.onGamesUpdated?.call();
},
@@ -224,11 +224,10 @@ class _ChooseGameViewState extends State<ChooseGameView> {
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;
}
}

View File

@@ -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<CreateGameView> createState() => _CreateGameViewState();
@@ -139,45 +139,59 @@ class _CreateGameViewState extends State<CreateGameView> {
if (isEditMode())
IconButton(
icon: const Icon(Icons.delete),
onPressed: widget.canDelete
? () async {
showDialog<bool>(
context: context,
builder: (context) => CustomAlertDialog(
title: loc.delete_game,
content: Text(loc.this_cannot_be_undone),
actions: [
CustomDialogAction(
onPressed: () =>
Navigator.of(context).pop(false),
text: loc.cancel,
),
CustomDialogAction(
onPressed: () =>
Navigator.of(context).pop(true),
text: loc.delete,
),
],
),
).then((confirmed) async {
if (confirmed == true && context.mounted) {
bool success = await db.gameDao.deleteGame(
gameId: widget.gameToEdit!.id,
);
if (!context.mounted) return;
if (success) {
widget.onGameChanged.call();
Navigator.of(
context,
).pop((game: widget.gameToEdit, delete: true));
} else {
if (!mounted) return;
showSnackbar(message: loc.error_deleting_game);
}
}
});
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),
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);
}
}
});
},
),
],
),

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

@@ -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);
});
});
}