diff --git a/analysis_options.yaml b/analysis_options.yaml index 04172d4..c0978e6 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,4 +11,8 @@ linter: prefer_const_literals_to_create_immutables: true unnecessary_const: true lines_longer_than_80_chars: false - constant_identifier_names: false \ No newline at end of file + constant_identifier_names: false + +analyzer: + exclude: + - lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart diff --git a/lib/data/dao/group_dao.dart b/lib/data/dao/group_dao.dart index c5abfd5..552b566 100644 --- a/lib/data/dao/group_dao.dart +++ b/lib/data/dao/group_dao.dart @@ -1,13 +1,14 @@ import 'package:drift/drift.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/db/tables/group_table.dart'; +import 'package:tallee/data/db/tables/match_table.dart'; import 'package:tallee/data/db/tables/player_group_table.dart'; import 'package:tallee/data/dto/group.dart'; import 'package:tallee/data/dto/player.dart'; part 'group_dao.g.dart'; -@DriftAccessor(tables: [GroupTable, PlayerGroupTable]) +@DriftAccessor(tables: [GroupTable, PlayerGroupTable, MatchTable]) class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { GroupDao(super.db); @@ -205,8 +206,6 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { return rowsAffected > 0; } - - /// Retrieves the number of groups in the database. Future getGroupCount() async { final count = @@ -235,10 +234,13 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { /// Replaces all players in a group with the provided list of players. /// Removes all existing players from the group and adds the new players. /// Also adds any new players to the player table if they don't exist. - Future replaceGroupPlayers({ + /// Returns `true` if the group exists and players were replaced, `false` otherwise. + Future replaceGroupPlayers({ required String groupId, required List newPlayers, }) async { + if (!await groupExists(groupId: groupId)) return false; + await db.transaction(() async { // Remove all existing players from the group final deleteQuery = delete(db.playerGroupTable) @@ -270,5 +272,6 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { ), ); }); + return true; } } diff --git a/lib/data/dao/group_dao.g.dart b/lib/data/dao/group_dao.g.dart index b9534b4..37a5586 100644 --- a/lib/data/dao/group_dao.g.dart +++ b/lib/data/dao/group_dao.g.dart @@ -8,4 +8,6 @@ mixin _$GroupDaoMixin on DatabaseAccessor { $PlayerTableTable get playerTable => attachedDatabase.playerTable; $PlayerGroupTableTable get playerGroupTable => attachedDatabase.playerGroupTable; + $GameTableTable get gameTable => attachedDatabase.gameTable; + $MatchTableTable get matchTable => attachedDatabase.matchTable; } diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index cc30b03..d3fd06f 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -268,6 +268,34 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { return count ?? 0; } + /// Retrieves all matches associated with the given [groupId]. + /// Queries the database directly, filtering by [groupId]. + Future> getGroupMatches({required String groupId}) async { + final query = select(matchTable)..where((m) => m.groupId.equals(groupId)); + final rows = await query.get(); + + return Future.wait( + rows.map((row) async { + final game = await db.gameDao.getGameById(gameId: row.gameId); + final group = await db.groupDao.getGroupById(groupId: groupId); + final players = + await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? []; + final winner = await db.matchDao.getWinner(matchId: row.id); + return Match( + id: row.id, + name: row.name ?? '', + game: game, + group: group, + players: players, + notes: row.notes ?? '', + createdAt: row.createdAt, + endedAt: row.endedAt, + winner: winner, + ); + }), + ); + } + /// Checks if a match with the given [matchId] exists in the database. /// Returns `true` if the match exists, otherwise `false`. Future matchExists({required String matchId}) async { @@ -338,6 +366,17 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { return rowsAffected > 0; } + /// Removes the group association of the match with the given [matchId]. + /// Sets the groupId to null. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future removeMatchGroup({required String matchId}) async { + final query = update(matchTable)..where((g) => g.id.equals(matchId)); + final rowsAffected = await query.write( + const MatchTableCompanion(groupId: Value(null)), + ); + return rowsAffected > 0; + } + /// Updates the createdAt timestamp of the match with the given [matchId]. /// Returns `true` if more than 0 rows were affected, otherwise `false`. Future updateMatchCreatedAt({ diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index fe14e93..227c7c0 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -1123,7 +1123,7 @@ class $MatchTableTable extends MatchTable type: DriftSqlType.string, requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES group_table (id) ON DELETE CASCADE', + 'REFERENCES group_table (id) ON DELETE SET NULL', ), ); static const VerificationMeta _nameMeta = const VerificationMeta('name'); @@ -2780,7 +2780,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { 'group_table', limitUpdateKind: UpdateKind.delete, ), - result: [TableUpdate('match_table', kind: UpdateKind.delete)], + result: [TableUpdate('match_table', kind: UpdateKind.update)], ), WritePropagation( on: TableUpdateQuery.onTableName( diff --git a/lib/data/db/tables/match_table.dart b/lib/data/db/tables/match_table.dart index 191e72c..25b0a73 100644 --- a/lib/data/db/tables/match_table.dart +++ b/lib/data/db/tables/match_table.dart @@ -7,13 +7,15 @@ class MatchTable extends Table { TextColumn get gameId => text().references(GameTable, #id, onDelete: KeyAction.cascade)(); // Nullable if there is no group associated with the match - TextColumn get groupId => - text().references(GroupTable, #id, onDelete: KeyAction.cascade).nullable()(); - TextColumn get name => text().nullable()(); + // onDelete: If a group gets deleted, groupId in the match gets set to null + TextColumn get groupId => text() + .references(GroupTable, #id, onDelete: KeyAction.setNull) + .nullable()(); + TextColumn get name => text()(); TextColumn get notes => text().nullable()(); DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get endedAt => dateTime().nullable()(); @override Set> get primaryKey => {id}; -} \ No newline at end of file +} 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 a128456..8829343 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,5 +1,6 @@ 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'; @@ -11,11 +12,13 @@ import 'package:tallee/presentation/widgets/player_selection.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; class CreateGroupView extends StatefulWidget { - const CreateGroupView({super.key, this.groupToEdit}); + const CreateGroupView({super.key, this.groupToEdit, this.onMembersChanged}); /// The group to edit, if any final Group? groupToEdit; + final VoidCallback? onMembersChanged; + @override State createState() => _CreateGroupViewState(); } @@ -69,49 +72,6 @@ class _CreateGroupViewState extends State { 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( @@ -122,6 +82,7 @@ class _CreateGroupViewState extends State { child: TextInputField( controller: _groupNameController, hintText: loc.group_name, + maxLength: Constants.MAX_GROUP_NAME_LENGTH, ), ), Expanded( @@ -144,42 +105,7 @@ class _CreateGroupViewState extends State { (_groupNameController.text.isEmpty || (selectedPlayers.length < 2)) ? null - : () async { - 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(), - description: '', - 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, updatedGroup); - } else { - showSnackbar( - message: widget.groupToEdit == null - ? loc.error_creating_group - : loc.error_editing_group, - ); - } - }, + : _saveGroup, ), const SizedBox(height: 20), ], @@ -189,6 +115,104 @@ class _CreateGroupViewState extends State { ); } + /// Saves the group by creating a new one or updating the existing one, + /// depending on whether the widget is in edit mode. + Future _saveGroup() async { + final loc = AppLocalizations.of(context); + late bool success; + Group? updatedGroup; + + if (widget.groupToEdit == null) { + success = await _createGroup(); + } else { + final result = await _editGroup(); + success = result.$1; + updatedGroup = result.$2; + } + + if (!mounted) return; + + if (success) { + Navigator.pop(context, updatedGroup); + } else { + showSnackbar( + message: widget.groupToEdit == null + ? loc.error_creating_group + : loc.error_editing_group, + ); + } + } + + /// Handles creating a new group and returns whether the operation was successful. + Future _createGroup() async { + final groupName = _groupNameController.text.trim(); + + final success = await db.groupDao.addGroup( + group: Group(name: groupName, description: '', members: selectedPlayers), + ); + + return success; + } + + /// Handles editing an existing group and returns a tuple of + /// (success, updatedGroup). + Future<(bool, Group?)> _editGroup() async { + final groupName = _groupNameController.text.trim(); + + Group? updatedGroup = Group( + id: widget.groupToEdit!.id, + name: groupName, + description: '', + members: selectedPlayers, + ); + + bool successfullNameChange = true; + bool successfullMemberChange = true; + + if (widget.groupToEdit!.name != groupName) { + successfullNameChange = await db.groupDao.updateGroupName( + groupId: widget.groupToEdit!.id, + newName: groupName, + ); + } + + if (widget.groupToEdit!.members != selectedPlayers) { + successfullMemberChange = await db.groupDao.replaceGroupPlayers( + groupId: widget.groupToEdit!.id, + newPlayers: selectedPlayers, + ); + await deleteObsoleteMatchGroupRelations(); + widget.onMembersChanged?.call(); + } + + final success = successfullNameChange && successfullMemberChange; + + return (success, updatedGroup); + } + + /// Removes the group association from matches that no longer belong to the edited group. + /// + /// After updating the group's members, matches that were previously linked to + /// this group but don't have any of the newly selected players are considered + /// obsolete. For each such match, the group association is removed by setting + /// its [groupId] to null. + Future deleteObsoleteMatchGroupRelations() async { + final groupMatches = await db.matchDao.getGroupMatches( + groupId: widget.groupToEdit!.id, + ); + + final selectedPlayerIds = selectedPlayers.map((p) => p.id).toSet(); + final relationshipsToDelete = groupMatches.where((match) { + return !match.players.any( + (player) => selectedPlayerIds.contains(player.id), + ); + }).toList(); + + for (var match in relationshipsToDelete) { + await db.matchDao.removeMatchGroup(matchId: match.id); + } + } + /// Displays a snackbar with the given message and optional action. /// /// [message] The message to display in the snackbar. diff --git a/lib/presentation/views/main_menu/group_view/group_detail_view.dart b/lib/presentation/views/main_menu/group_view/group_detail_view.dart index ad88d66..c9ffa25 100644 --- a/lib/presentation/views/main_menu/group_view/group_detail_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_detail_view.dart @@ -191,7 +191,12 @@ class _GroupDetailViewState extends State { context, adaptivePageRoute( builder: (context) { - return CreateGroupView(groupToEdit: _group); + return CreateGroupView( + groupToEdit: _group, + onMembersChanged: () { + _loadStatistics(); + }, + ); }, ), ); @@ -242,10 +247,8 @@ class _GroupDetailViewState extends State { /// Loads statistics for this group Future _loadStatistics() async { - final matches = await db.matchDao.getAllMatches(); - final groupMatches = matches - .where((match) => match.group?.id == _group.id) - .toList(); + isLoading = true; + final groupMatches = await db.matchDao.getGroupMatches(groupId: _group.id); setState(() { totalMatches = groupMatches.length; @@ -260,7 +263,9 @@ class _GroupDetailViewState extends State { // Count wins for each player for (var match in matches) { - if (match.winner != null) { + if (match.winner != null && + _group.members.any((m) => m.id == match.winner?.id)) { + print(match.winner); bestPlayerCounts.update( match.winner!, (value) => value + 1, 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 1bf732c..8138957 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 @@ -363,6 +363,7 @@ class _CreateMatchViewState extends State { final match = widget.matchToEdit!; _matchNameController.text = match.name; selectedPlayers = match.players; + selectedGameIndex = 0; if (match.group != null) { selectedGroup = match.group; diff --git a/pubspec.yaml b/pubspec.yaml index bdb1efe..c812953 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,9 +31,9 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.5.4 + build_runner: ^2.7.0 dart_pubspec_licenses: ^3.0.14 - drift_dev: ^2.27.0 + drift_dev: ^2.29.0 flutter_lints: ^6.0.0 flutter: diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index ea80369..f5fbeb6 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -42,7 +42,7 @@ void main() { testPlayer4 = Player(name: 'Diana', description: ''); testPlayer5 = Player(name: 'Eve', description: ''); testGroup1 = Group( - name: 'Test Group 2', + name: 'Test Group 1', description: '', members: [testPlayer1, testPlayer2, testPlayer3], ); @@ -307,5 +307,69 @@ void main() { expect(fetchedMatch.winner, isNotNull); expect(fetchedMatch.winner!.id, testPlayer5.id); }); + + test( + 'removeMatchGroup removes group from match with existing group', + () async { + await database.matchDao.addMatch(match: testMatch1); + + final removed = await database.matchDao.removeMatchGroup( + matchId: testMatch1.id, + ); + expect(removed, isTrue); + + final updatedMatch = await database.matchDao.getMatchById( + matchId: testMatch1.id, + ); + expect(updatedMatch.group, null); + expect(updatedMatch.game.id, testMatch1.game.id); + expect(updatedMatch.name, testMatch1.name); + expect(updatedMatch.notes, testMatch1.notes); + }, + ); + + test( + 'removeMatchGroup on match that already has no group still succeeds', + () async { + await database.matchDao.addMatch(match: testMatchOnlyPlayers); + + final removed = await database.matchDao.removeMatchGroup( + matchId: testMatchOnlyPlayers.id, + ); + expect(removed, isTrue); + + final updatedMatch = await database.matchDao.getMatchById( + matchId: testMatchOnlyPlayers.id, + ); + expect(updatedMatch.group, null); + }, + ); + + test('removeMatchGroup on non-existing match returns false', () async { + final removed = await database.matchDao.removeMatchGroup( + matchId: 'non-existing-id', + ); + expect(removed, isFalse); + }); + + test('Fetching all matches related to a group', () async { + var matches = await database.matchDao.getGroupMatches( + groupId: 'non-existing-id', + ); + + expect(matches, isEmpty); + + await database.matchDao.addMatch(match: testMatch1); + print(await database.matchDao.getAllMatches()); + + matches = await database.matchDao.getGroupMatches(groupId: testGroup1.id); + + expect(matches, isNotEmpty); + + final match = matches.first; + expect(match.id, testMatch1.id); + expect(match.group, isNotNull); + expect(match.group!.id, testGroup1.id); + }); }); }