diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..a18a1cd Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..d3f4435 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,7 +1,7 @@ - + + @color/app_icon_background + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..da964ae 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -4,7 +4,7 @@ @@ -14,24 +17,27 @@ + - + + + - - - - - + - + + - + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..3ff2769 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 320eaf7..8c809b0 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -1,2 +1,24 @@ /// Button types used for styling the [CustomWidthButton] enum ButtonType { primary, secondary, tertiary } + +/// Result types for import operations in the [SettingsView] +/// - [ImportResult.success]: The import operation was successful. +/// - [ImportResult.canceled]: The import operation was canceled by the user. +/// - [ImportResult.fileReadError]: There was an error reading the selected file. +/// - [ImportResult.invalidSchema]: The JSON schema of the imported data is invalid. +/// - [ImportResult.formatException]: A format exception occurred during import. +/// - [ImportResult.unknownException]: An exception occurred during import. +enum ImportResult { + success, + canceled, + fileReadError, + invalidSchema, + formatException, + unknownException, +} + +/// Result types for export operations in the [SettingsView] +/// - [ExportResult.success]: The export operation was successful. +/// - [ExportResult.canceled]: The export operation was canceled by the user. +/// - [ExportResult.unknownException]: An exception occurred during export. +enum ExportResult { success, canceled, unknownException } diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index 94d010c..18792b5 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -15,11 +15,24 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { Future> getAllGames() async { final query = select(gameTable); final result = await query.get(); - return result - .map( - (row) => Game(id: row.id, name: row.name, createdAt: row.createdAt), - ) - .toList(); + + return Future.wait( + result.map((row) async { + final group = await db.groupGameDao.getGroupOfGame(gameId: row.id); + final players = await db.playerGameDao.getPlayersOfGame(gameId: row.id); + final winner = row.winnerId != null + ? await db.playerDao.getPlayerById(playerId: row.winnerId!) + : null; + return Game( + id: row.id, + name: row.name, + group: group, + players: players, + createdAt: row.createdAt, + winner: winner, + ); + }), + ); } /// Retrieves a [Game] by its [gameId]. @@ -29,11 +42,15 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { List? players; if (await db.playerGameDao.gameHasPlayers(gameId: gameId)) { - players = await db.playerGameDao.getPlayersByGameId(gameId: gameId); + players = await db.playerGameDao.getPlayersOfGame(gameId: gameId); } Group? group; - if (await db.groupGameDao.hasGameGroup(gameId: gameId)) { - group = await db.groupGameDao.getGroupByGameId(gameId: gameId); + if (await db.groupGameDao.gameHasGroup(gameId: gameId)) { + group = await db.groupGameDao.getGroupOfGame(gameId: gameId); + } + Player? winner; + if (result.winnerId != null) { + winner = await db.playerDao.getPlayerById(playerId: result.winnerId!); } return Game( @@ -41,7 +58,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { name: result.name, players: players, group: group, - winner: result.winnerId, + winner: winner, createdAt: result.createdAt, ); } @@ -50,23 +67,157 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Also adds associated players and group if they exist. Future addGame({required Game game}) async { await db.transaction(() async { - for (final p in game.players ?? []) { - await db.playerDao.addPlayer(player: p); - await db.playerGameDao.addPlayerToGame(gameId: game.id, playerId: p.id); - } - if (game.group != null) { - await db.groupDao.addGroup(group: game.group!); - await db.groupGameDao.addGroupToGame(game.id, game.group!.id); - } await into(gameTable).insert( GameTableCompanion.insert( id: game.id, name: game.name, - winnerId: game.winner, + winnerId: Value(game.winner?.id), createdAt: game.createdAt, ), mode: InsertMode.insertOrReplace, ); + + if (game.players != null) { + await db.playerDao.addPlayers(players: game.players!); + for (final p in game.players ?? []) { + await db.playerGameDao.addPlayerToGame( + gameId: game.id, + playerId: p.id, + ); + } + } + + if (game.group != null) { + await db.groupDao.addGroup(group: game.group!); + await db.groupGameDao.addGroupToGame(game.id, game.group!.id); + } + }); + } + + Future addGames({required List games}) async { + if (games.isEmpty) return; + await db.transaction(() async { + // Add all games in batch + await db.batch( + (b) => b.insertAll( + gameTable, + games + .map( + (game) => GameTableCompanion.insert( + id: game.id, + name: game.name, + createdAt: game.createdAt, + winnerId: Value(game.winner?.id), + ), + ) + .toList(), + mode: InsertMode.insertOrReplace, + ), + ); + + // Add all groups of the games in batch + await db.batch( + (b) => b.insertAll( + db.groupTable, + games + .where((game) => game.group != null) + .map( + (game) => GroupTableCompanion.insert( + id: game.group!.id, + name: game.group!.name, + createdAt: game.group!.createdAt, + ), + ) + .toList(), + mode: InsertMode.insertOrReplace, + ), + ); + + // Add all players of the games in batch (unique) + final uniquePlayers = {}; + for (final game in games) { + if (game.players != null) { + for (final p in game.players!) { + uniquePlayers[p.id] = p; + } + } + // Also include members of groups + if (game.group != null) { + for (final m in game.group!.members) { + uniquePlayers[m.id] = m; + } + } + } + + if (uniquePlayers.isNotEmpty) { + await db.batch( + (b) => b.insertAll( + db.playerTable, + uniquePlayers.values + .map( + (p) => PlayerTableCompanion.insert( + id: p.id, + name: p.name, + createdAt: p.createdAt, + ), + ) + .toList(), + mode: InsertMode.insertOrReplace, + ), + ); + } + + // Add all player-game associations in batch + await db.batch((b) { + for (final game in games) { + if (game.players != null) { + for (final p in game.players ?? []) { + b.insert( + db.playerGameTable, + PlayerGameTableCompanion.insert( + gameId: game.id, + playerId: p.id, + ), + mode: InsertMode.insertOrReplace, + ); + } + } + } + }); + + // Add all player-group associations in batch + await db.batch((b) { + for (final game in games) { + if (game.group != null) { + for (final m in game.group!.members) { + b.insert( + db.playerGroupTable, + PlayerGroupTableCompanion.insert( + playerId: m.id, + groupId: game.group!.id, + ), + mode: InsertMode.insertOrReplace, + ); + } + } + } + }); + + // Add all group-game associations in batch + await db.batch((b) { + for (final game in games) { + if (game.group != null) { + b.insert( + db.groupGameTable, + GroupGameTableCompanion.insert( + gameId: game.id, + groupId: game.group!.id, + ), + mode: InsertMode.insertOrReplace, + ); + } + } + }); }); } @@ -94,4 +245,12 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { final result = await query.getSingleOrNull(); return result != null; } + + /// Deletes all games from the database. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future deleteAllGames() async { + final query = delete(gameTable); + final rowsAffected = await query.go(); + return rowsAffected > 0; + } } diff --git a/lib/data/dao/game_dao.g.dart b/lib/data/dao/game_dao.g.dart index ebf5524..b5a29fe 100644 --- a/lib/data/dao/game_dao.g.dart +++ b/lib/data/dao/game_dao.g.dart @@ -4,6 +4,5 @@ part of 'game_dao.dart'; // ignore_for_file: type=lint mixin _$GameDaoMixin on DatabaseAccessor { - $PlayerTableTable get playerTable => attachedDatabase.playerTable; $GameTableTable get gameTable => attachedDatabase.gameTable; } diff --git a/lib/data/dao/group_dao.dart b/lib/data/dao/group_dao.dart index 3378948..fbb4d6f 100644 --- a/lib/data/dao/group_dao.dart +++ b/lib/data/dao/group_dao.dart @@ -16,7 +16,7 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { final result = await query.get(); return Future.wait( result.map((groupData) async { - final members = await db.playerGroupDao.getPlayersOfGroupById( + final members = await db.playerGroupDao.getPlayersOfGroup( groupId: groupData.id, ); return Group( @@ -34,7 +34,7 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { final query = select(groupTable)..where((g) => g.id.equals(groupId)); final result = await query.getSingle(); - List members = await db.playerGroupDao.getPlayersOfGroupById( + List members = await db.playerGroupDao.getPlayersOfGroup( groupId: groupId, ); @@ -57,6 +57,10 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { name: group.name, createdAt: group.createdAt, ), + mode: InsertMode.insertOrReplace, + ); + await Future.wait( + group.members.map((player) => db.playerDao.addPlayer(player: player)), ); await db.batch( (b) => b.insertAll( @@ -69,17 +73,94 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { ), ) .toList(), + mode: InsertMode.insertOrReplace, ), ); - await Future.wait( - group.members.map((player) => db.playerDao.addPlayer(player: player)), - ); }); return true; } return false; } + /// Adds multiple groups to the database. + /// Also adds the group's members to the [PlayerGroupTable]. + Future addGroups({required List groups}) async { + if (groups.isEmpty) return; + await db.transaction(() async { + // Deduplicate groups by id - keep first occurrence + final Map uniqueGroups = {}; + for (final g in groups) { + uniqueGroups.putIfAbsent(g.id, () => g); + } + + // Insert unique groups in batch + await db.batch( + (b) => b.insertAll( + groupTable, + uniqueGroups.values + .map( + (group) => GroupTableCompanion.insert( + id: group.id, + name: group.name, + createdAt: group.createdAt, + ), + ) + .toList(), + mode: InsertMode.insertOrReplace, + ), + ); + + // Collect unique players from all groups + final uniquePlayers = {}; + for (final g in uniqueGroups.values) { + for (final m in g.members) { + uniquePlayers[m.id] = m; + } + } + + if (uniquePlayers.isNotEmpty) { + await db.batch( + (b) => b.insertAll( + db.playerTable, + uniquePlayers.values + .map( + (p) => PlayerTableCompanion.insert( + id: p.id, + name: p.name, + createdAt: p.createdAt, + ), + ) + .toList(), + mode: InsertMode.insertOrReplace, + ), + ); + } + + // Prepare all player-group associations in one list (unique pairs) + final Set seenPairs = {}; + final List pgRows = []; + for (final g in uniqueGroups.values) { + for (final m in g.members) { + final key = '${m.id}|${g.id}'; + if (!seenPairs.contains(key)) { + seenPairs.add(key); + pgRows.add( + PlayerGroupTableCompanion.insert(playerId: m.id, groupId: g.id), + ); + } + } + } + + if (pgRows.isNotEmpty) { + await db.batch((b) { + for (final pg in pgRows) { + b.insert(db.playerGroupTable, pg, mode: InsertMode.insertOrReplace); + } + }); + } + }); + } + /// Deletes the group with the given [id] from the database. /// Returns `true` if more than 0 rows were affected, otherwise `false`. Future deleteGroup({required String groupId}) async { @@ -117,4 +198,12 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { final result = await query.getSingleOrNull(); return result != null; } + + /// Deletes all groups from the database. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future deleteAllGroups() async { + final query = delete(groupTable); + final rowsAffected = await query.go(); + return rowsAffected > 0; + } } diff --git a/lib/data/dao/group_game_dao.dart b/lib/data/dao/group_game_dao.dart index d3b30ca..f3ddcc7 100644 --- a/lib/data/dao/group_game_dao.dart +++ b/lib/data/dao/group_game_dao.dart @@ -10,9 +10,33 @@ class GroupGameDao extends DatabaseAccessor with _$GroupGameDaoMixin { GroupGameDao(super.db); + /// Associates a group with a game by inserting a record into the + /// [GroupGameTable]. + Future addGroupToGame(String gameId, String groupId) async { + await into(groupGameTable).insert( + GroupGameTableCompanion.insert(groupId: groupId, gameId: gameId), + mode: InsertMode.insertOrReplace, + ); + } + + /// Retrieves the [Group] associated with the given [gameId]. + /// Returns `null` if no group is found. + Future getGroupOfGame({required String gameId}) async { + final result = await (select( + groupGameTable, + )..where((g) => g.gameId.equals(gameId))).getSingleOrNull(); + + if (result == null) { + return null; + } + + final group = await db.groupDao.getGroupById(groupId: result.groupId); + return group; + } + /// Checks if there is a group associated with the given [gameId]. /// Returns `true` if there is a group, otherwise `false`. - Future hasGameGroup({required String gameId}) async { + Future gameHasGroup({required String gameId}) async { final count = await (selectOnly(groupGameTable) ..where(groupGameTable.gameId.equals(gameId)) @@ -22,21 +46,34 @@ class GroupGameDao extends DatabaseAccessor return (count ?? 0) > 0; } - /// Retrieves the [Group] associated with the given [gameId]. - Future getGroupByGameId({required String gameId}) async { - final result = await (select( - groupGameTable, - )..where((g) => g.gameId.equals(gameId))).getSingle(); - - final group = await db.groupDao.getGroupById(groupId: result.groupId); - return group; + /// Checks if a specific group is associated with a specific game. + /// Returns `true` if the group is in the game, otherwise `false`. + Future isGroupInGame({ + required String gameId, + required String groupId, + }) async { + final count = + await (selectOnly(groupGameTable) + ..where( + groupGameTable.gameId.equals(gameId) & + groupGameTable.groupId.equals(groupId), + ) + ..addColumns([groupGameTable.groupId.count()])) + .map((row) => row.read(groupGameTable.groupId.count())) + .getSingle(); + return (count ?? 0) > 0; } - /// Associates a group with a game by inserting a record into the - /// [GroupGameTable]. - Future addGroupToGame(String gameId, String groupId) async { - await into( - groupGameTable, - ).insert(GroupGameTableCompanion.insert(groupId: groupId, gameId: gameId)); + /// Removes the association of a group from a game based on [groupId] and + /// [gameId]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future removeGroupFromGame({ + required String gameId, + required String groupId, + }) async { + final query = delete(groupGameTable) + ..where((g) => g.gameId.equals(gameId) & g.groupId.equals(groupId)); + final rowsAffected = await query.go(); + return rowsAffected > 0; } } diff --git a/lib/data/dao/group_game_dao.g.dart b/lib/data/dao/group_game_dao.g.dart index 426f192..735a35f 100644 --- a/lib/data/dao/group_game_dao.g.dart +++ b/lib/data/dao/group_game_dao.g.dart @@ -5,7 +5,6 @@ part of 'group_game_dao.dart'; // ignore_for_file: type=lint mixin _$GroupGameDaoMixin on DatabaseAccessor { $GroupTableTable get groupTable => attachedDatabase.groupTable; - $PlayerTableTable get playerTable => attachedDatabase.playerTable; $GameTableTable get gameTable => attachedDatabase.gameTable; $GroupGameTableTable get groupGameTable => attachedDatabase.groupGameTable; } diff --git a/lib/data/dao/player_dao.dart b/lib/data/dao/player_dao.dart index 36f9305..53e251f 100644 --- a/lib/data/dao/player_dao.dart +++ b/lib/data/dao/player_dao.dart @@ -42,12 +42,36 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { name: player.name, createdAt: player.createdAt, ), + mode: InsertMode.insertOrReplace, ); return true; } return false; } + /// Adds multiple [players] to the database in a batch operation. + Future addPlayers({required List players}) async { + if (players.isEmpty) return false; + + await db.batch( + (b) => b.insertAll( + playerTable, + players + .map( + (player) => PlayerTableCompanion.insert( + id: player.id, + name: player.name, + createdAt: player.createdAt, + ), + ) + .toList(), + mode: InsertMode.insertOrReplace, + ), + ); + + return true; + } + /// Deletes the player with the given [id] from the database. /// Returns `true` if the player was deleted, `false` if the player did not exist. Future deletePlayer({required String playerId}) async { @@ -82,4 +106,12 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { .getSingle(); return count ?? 0; } + + /// Deletes all players from the database. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future deleteAllPlayers() async { + final query = delete(playerTable); + final rowsAffected = await query.go(); + return rowsAffected > 0; + } } diff --git a/lib/data/dao/player_game_dao.dart b/lib/data/dao/player_game_dao.dart index 8f367f8..ef15a80 100644 --- a/lib/data/dao/player_game_dao.dart +++ b/lib/data/dao/player_game_dao.dart @@ -10,6 +10,34 @@ class PlayerGameDao extends DatabaseAccessor with _$PlayerGameDaoMixin { PlayerGameDao(super.db); + /// Associates a player with a game by inserting a record into the + /// [PlayerGameTable]. + Future addPlayerToGame({ + required String gameId, + required String playerId, + }) async { + await into(playerGameTable).insert( + PlayerGameTableCompanion.insert(playerId: playerId, gameId: gameId), + mode: InsertMode.insertOrReplace, + ); + } + + /// Retrieves a list of [Player]s associated with the given [gameId]. + /// Returns null if no players are found. + Future?> getPlayersOfGame({required String gameId}) async { + final result = await (select( + playerGameTable, + )..where((p) => p.gameId.equals(gameId))).get(); + + if (result.isEmpty) return null; + + final futures = result.map( + (row) => db.playerDao.getPlayerById(playerId: row.playerId), + ); + final players = await Future.wait(futures); + return players; + } + /// Checks if there are any players associated with the given [gameId]. /// Returns `true` if there are players, otherwise `false`. Future gameHasPlayers({required String gameId}) async { @@ -22,30 +50,33 @@ class PlayerGameDao extends DatabaseAccessor return (count ?? 0) > 0; } - /// Retrieves a list of [Player]s associated with the given [gameId]. - /// Returns an empty list if no players are found. - Future> getPlayersByGameId({required String gameId}) async { - final result = await (select( - playerGameTable, - )..where((p) => p.gameId.equals(gameId))).get(); - - if (result.isEmpty) return []; - - final futures = result.map( - (row) => db.playerDao.getPlayerById(playerId: row.playerId), - ); - final players = await Future.wait(futures); - return players.whereType().toList(); - } - - /// Associates a player with a game by inserting a record into the - /// [PlayerGameTable]. - Future addPlayerToGame({ + /// Checks if a specific player is associated with a specific game. + /// Returns `true` if the player is in the game, otherwise `false`. + Future isPlayerInGame({ required String gameId, required String playerId, }) async { - await into(playerGameTable).insert( - PlayerGameTableCompanion.insert(playerId: playerId, gameId: gameId), - ); + final count = + await (selectOnly(playerGameTable) + ..where(playerGameTable.gameId.equals(gameId)) + ..where(playerGameTable.playerId.equals(playerId)) + ..addColumns([playerGameTable.playerId.count()])) + .map((row) => row.read(playerGameTable.playerId.count())) + .getSingle(); + return (count ?? 0) > 0; + } + + /// Removes the association of a player with a game by deleting the record + /// from the [PlayerGameTable]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future removePlayerFromGame({ + required String gameId, + required String playerId, + }) async { + final query = delete(playerGameTable) + ..where((pg) => pg.gameId.equals(gameId)) + ..where((pg) => pg.playerId.equals(playerId)); + final rowsAffected = await query.go(); + return rowsAffected > 0; } } diff --git a/lib/data/dao/player_group_dao.dart b/lib/data/dao/player_group_dao.dart index fe067ae..8cf96c2 100644 --- a/lib/data/dao/player_group_dao.dart +++ b/lib/data/dao/player_group_dao.dart @@ -10,8 +10,34 @@ class PlayerGroupDao extends DatabaseAccessor with _$PlayerGroupDaoMixin { PlayerGroupDao(super.db); + /// No need for a groupHasPlayers method since the members attribute is + /// not nullable + + /// Adds a [player] to a group with the given [groupId]. + /// If the player is already in the group, no action is taken. + /// If the player does not exist in the player table, they are added. + /// Returns `true` if the player was added, otherwise `false`. + Future addPlayerToGroup({ + required Player player, + required String groupId, + }) async { + if (await isPlayerInGroup(playerId: player.id, groupId: groupId)) { + return false; + } + + if (!await db.playerDao.playerExists(playerId: player.id)) { + db.playerDao.addPlayer(player: player); + } + + await into(playerGroupTable).insert( + PlayerGroupTableCompanion.insert(playerId: player.id, groupId: groupId), + ); + + return true; + } + /// Retrieves all players belonging to a specific group by [groupId]. - Future> getPlayersOfGroupById({required String groupId}) async { + Future> getPlayersOfGroup({required String groupId}) async { final query = select(playerGroupTable) ..where((pG) => pG.groupId.equals(groupId)); final result = await query.get(); @@ -38,29 +64,6 @@ class PlayerGroupDao extends DatabaseAccessor return rowsAffected > 0; } - /// Adds a [player] to a group with the given [groupId]. - /// If the player is already in the group, no action is taken. - /// If the player does not exist in the player table, they are added. - /// Returns `true` if the player was added, otherwise `false`. - Future addPlayerToGroup({ - required Player player, - required String groupId, - }) async { - if (await isPlayerInGroup(playerId: player.id, groupId: groupId)) { - return false; - } - - if (await db.playerDao.playerExists(playerId: player.id) == false) { - db.playerDao.addPlayer(player: player); - } - - await into(playerGroupTable).insert( - PlayerGroupTableCompanion.insert(playerId: player.id, groupId: groupId), - ); - - return true; - } - /// Checks if a player with [playerId] is in the group with [groupId]. /// Returns `true` if the player is in the group, otherwise `false`. Future isPlayerInGroup({ diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 73ad73e..704e1f0 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -40,6 +40,15 @@ class AppDatabase extends _$AppDatabase { @override int get schemaVersion => 1; + @override + MigrationStrategy get migration { + return MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON'); + }, + ); + } + static QueryExecutor _openConnection() { return driftDatabase( name: 'gametracker_db', diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index 3f10169..f211d0c 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -552,12 +552,9 @@ class $GameTableTable extends GameTable late final GeneratedColumn winnerId = GeneratedColumn( 'winner_id', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES player_table (id) ON DELETE CASCADE', - ), + requiredDuringInsert: false, ); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', @@ -602,8 +599,6 @@ class $GameTableTable extends GameTable _winnerIdMeta, winnerId.isAcceptableOrUnknown(data['winner_id']!, _winnerIdMeta), ); - } else if (isInserting) { - context.missing(_winnerIdMeta); } if (data.containsKey('created_at')) { context.handle( @@ -633,7 +628,7 @@ class $GameTableTable extends GameTable winnerId: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}winner_id'], - )!, + ), createdAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}created_at'], @@ -650,12 +645,12 @@ class $GameTableTable extends GameTable class GameTableData extends DataClass implements Insertable { final String id; final String name; - final String winnerId; + final String? winnerId; final DateTime createdAt; const GameTableData({ required this.id, required this.name, - required this.winnerId, + this.winnerId, required this.createdAt, }); @override @@ -663,7 +658,9 @@ class GameTableData extends DataClass implements Insertable { final map = {}; map['id'] = Variable(id); map['name'] = Variable(name); - map['winner_id'] = Variable(winnerId); + if (!nullToAbsent || winnerId != null) { + map['winner_id'] = Variable(winnerId); + } map['created_at'] = Variable(createdAt); return map; } @@ -672,7 +669,9 @@ class GameTableData extends DataClass implements Insertable { return GameTableCompanion( id: Value(id), name: Value(name), - winnerId: Value(winnerId), + winnerId: winnerId == null && nullToAbsent + ? const Value.absent() + : Value(winnerId), createdAt: Value(createdAt), ); } @@ -685,7 +684,7 @@ class GameTableData extends DataClass implements Insertable { return GameTableData( id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), - winnerId: serializer.fromJson(json['winnerId']), + winnerId: serializer.fromJson(json['winnerId']), createdAt: serializer.fromJson(json['createdAt']), ); } @@ -695,7 +694,7 @@ class GameTableData extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'name': serializer.toJson(name), - 'winnerId': serializer.toJson(winnerId), + 'winnerId': serializer.toJson(winnerId), 'createdAt': serializer.toJson(createdAt), }; } @@ -703,12 +702,12 @@ class GameTableData extends DataClass implements Insertable { GameTableData copyWith({ String? id, String? name, - String? winnerId, + Value winnerId = const Value.absent(), DateTime? createdAt, }) => GameTableData( id: id ?? this.id, name: name ?? this.name, - winnerId: winnerId ?? this.winnerId, + winnerId: winnerId.present ? winnerId.value : this.winnerId, createdAt: createdAt ?? this.createdAt, ); GameTableData copyWithCompanion(GameTableCompanion data) { @@ -746,7 +745,7 @@ class GameTableData extends DataClass implements Insertable { class GameTableCompanion extends UpdateCompanion { final Value id; final Value name; - final Value winnerId; + final Value winnerId; final Value createdAt; final Value rowid; const GameTableCompanion({ @@ -759,12 +758,11 @@ class GameTableCompanion extends UpdateCompanion { GameTableCompanion.insert({ required String id, required String name, - required String winnerId, + this.winnerId = const Value.absent(), required DateTime createdAt, this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), - winnerId = Value(winnerId), createdAt = Value(createdAt); static Insertable custom({ Expression? id, @@ -785,7 +783,7 @@ class GameTableCompanion extends UpdateCompanion { GameTableCompanion copyWith({ Value? id, Value? name, - Value? winnerId, + Value? winnerId, Value? createdAt, Value? rowid, }) { @@ -1538,13 +1536,6 @@ abstract class _$AppDatabase extends GeneratedDatabase { ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ - WritePropagation( - on: TableUpdateQuery.onTableName( - 'player_table', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('game_table', kind: UpdateKind.delete)], - ), WritePropagation( on: TableUpdateQuery.onTableName( 'player_table', @@ -1609,24 +1600,6 @@ final class $$PlayerTableTableReferences extends BaseReferences<_$AppDatabase, $PlayerTableTable, PlayerTableData> { $$PlayerTableTableReferences(super.$_db, super.$_table, super.$_typedResult); - static MultiTypedResultKey<$GameTableTable, List> - _gameTableRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.gameTable, - aliasName: $_aliasNameGenerator(db.playerTable.id, db.gameTable.winnerId), - ); - - $$GameTableTableProcessedTableManager get gameTableRefs { - final manager = $$GameTableTableTableManager( - $_db, - $_db.gameTable, - ).filter((f) => f.winnerId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_gameTableRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - static MultiTypedResultKey<$PlayerGroupTableTable, List> _playerGroupTableRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.playerGroupTable, @@ -1698,31 +1671,6 @@ class $$PlayerTableTableFilterComposer builder: (column) => ColumnFilters(column), ); - Expression gameTableRefs( - Expression Function($$GameTableTableFilterComposer f) f, - ) { - final $$GameTableTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.gameTable, - getReferencedColumn: (t) => t.winnerId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$GameTableTableFilterComposer( - $db: $db, - $table: $db.gameTable, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression playerGroupTableRefs( Expression Function($$PlayerGroupTableTableFilterComposer f) f, ) { @@ -1817,31 +1765,6 @@ class $$PlayerTableTableAnnotationComposer GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - Expression gameTableRefs( - Expression Function($$GameTableTableAnnotationComposer a) f, - ) { - final $$GameTableTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.gameTable, - getReferencedColumn: (t) => t.winnerId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$GameTableTableAnnotationComposer( - $db: $db, - $table: $db.gameTable, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression playerGroupTableRefs( Expression Function($$PlayerGroupTableTableAnnotationComposer a) f, ) { @@ -1907,7 +1830,6 @@ class $$PlayerTableTableTableManager (PlayerTableData, $$PlayerTableTableReferences), PlayerTableData, PrefetchHooks Function({ - bool gameTableRefs, bool playerGroupTableRefs, bool playerGameTableRefs, }) @@ -1956,42 +1878,16 @@ class $$PlayerTableTableTableManager ) .toList(), prefetchHooksCallback: - ({ - gameTableRefs = false, - playerGroupTableRefs = false, - playerGameTableRefs = false, - }) { + ({playerGroupTableRefs = false, playerGameTableRefs = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ - if (gameTableRefs) db.gameTable, if (playerGroupTableRefs) db.playerGroupTable, if (playerGameTableRefs) db.playerGameTable, ], addJoins: null, getPrefetchedDataCallback: (items) async { return [ - if (gameTableRefs) - await $_getPrefetchedData< - PlayerTableData, - $PlayerTableTable, - GameTableData - >( - currentTable: table, - referencedTable: $$PlayerTableTableReferences - ._gameTableRefsTable(db), - managerFromTypedResult: (p0) => - $$PlayerTableTableReferences( - db, - table, - p0, - ).gameTableRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.winnerId == item.id, - ), - typedResults: items, - ), if (playerGroupTableRefs) await $_getPrefetchedData< PlayerTableData, @@ -2055,7 +1951,6 @@ typedef $$PlayerTableTableProcessedTableManager = (PlayerTableData, $$PlayerTableTableReferences), PlayerTableData, PrefetchHooks Function({ - bool gameTableRefs, bool playerGroupTableRefs, bool playerGameTableRefs, }) @@ -2436,7 +2331,7 @@ typedef $$GameTableTableCreateCompanionBuilder = GameTableCompanion Function({ required String id, required String name, - required String winnerId, + Value winnerId, required DateTime createdAt, Value rowid, }); @@ -2444,7 +2339,7 @@ typedef $$GameTableTableUpdateCompanionBuilder = GameTableCompanion Function({ Value id, Value name, - Value winnerId, + Value winnerId, Value createdAt, Value rowid, }); @@ -2453,25 +2348,6 @@ final class $$GameTableTableReferences extends BaseReferences<_$AppDatabase, $GameTableTable, GameTableData> { $$GameTableTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $PlayerTableTable _winnerIdTable(_$AppDatabase db) => - db.playerTable.createAlias( - $_aliasNameGenerator(db.gameTable.winnerId, db.playerTable.id), - ); - - $$PlayerTableTableProcessedTableManager get winnerId { - final $_column = $_itemColumn('winner_id')!; - - final manager = $$PlayerTableTableTableManager( - $_db, - $_db.playerTable, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_winnerIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - static MultiTypedResultKey<$PlayerGameTableTable, List> _playerGameTableRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.playerGameTable, @@ -2530,34 +2406,16 @@ class $$GameTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get winnerId => $composableBuilder( + column: $table.winnerId, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnFilters(column), ); - $$PlayerTableTableFilterComposer get winnerId { - final $$PlayerTableTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.winnerId, - referencedTable: $db.playerTable, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PlayerTableTableFilterComposer( - $db: $db, - $table: $db.playerTable, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - Expression playerGameTableRefs( Expression Function($$PlayerGameTableTableFilterComposer f) f, ) { @@ -2628,33 +2486,15 @@ class $$GameTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get winnerId => $composableBuilder( + column: $table.winnerId, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnOrderings(column), ); - - $$PlayerTableTableOrderingComposer get winnerId { - final $$PlayerTableTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.winnerId, - referencedTable: $db.playerTable, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PlayerTableTableOrderingComposer( - $db: $db, - $table: $db.playerTable, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } class $$GameTableTableAnnotationComposer @@ -2672,32 +2512,12 @@ class $$GameTableTableAnnotationComposer GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get winnerId => + $composableBuilder(column: $table.winnerId, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - $$PlayerTableTableAnnotationComposer get winnerId { - final $$PlayerTableTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.winnerId, - referencedTable: $db.playerTable, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PlayerTableTableAnnotationComposer( - $db: $db, - $table: $db.playerTable, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - Expression playerGameTableRefs( Expression Function($$PlayerGameTableTableAnnotationComposer a) f, ) { @@ -2763,7 +2583,6 @@ class $$GameTableTableTableManager (GameTableData, $$GameTableTableReferences), GameTableData, PrefetchHooks Function({ - bool winnerId, bool playerGameTableRefs, bool groupGameTableRefs, }) @@ -2783,7 +2602,7 @@ class $$GameTableTableTableManager ({ Value id = const Value.absent(), Value name = const Value.absent(), - Value winnerId = const Value.absent(), + Value winnerId = const Value.absent(), Value createdAt = const Value.absent(), Value rowid = const Value.absent(), }) => GameTableCompanion( @@ -2797,7 +2616,7 @@ class $$GameTableTableTableManager ({ required String id, required String name, - required String winnerId, + Value winnerId = const Value.absent(), required DateTime createdAt, Value rowid = const Value.absent(), }) => GameTableCompanion.insert( @@ -2816,49 +2635,14 @@ class $$GameTableTableTableManager ) .toList(), prefetchHooksCallback: - ({ - winnerId = false, - playerGameTableRefs = false, - groupGameTableRefs = false, - }) { + ({playerGameTableRefs = false, groupGameTableRefs = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ if (playerGameTableRefs) db.playerGameTable, if (groupGameTableRefs) db.groupGameTable, ], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (winnerId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.winnerId, - referencedTable: $$GameTableTableReferences - ._winnerIdTable(db), - referencedColumn: $$GameTableTableReferences - ._winnerIdTable(db) - .id, - ) - as T; - } - - return state; - }, + addJoins: null, getPrefetchedDataCallback: (items) async { return [ if (playerGameTableRefs) @@ -2924,7 +2708,6 @@ typedef $$GameTableTableProcessedTableManager = (GameTableData, $$GameTableTableReferences), GameTableData, PrefetchHooks Function({ - bool winnerId, bool playerGameTableRefs, bool groupGameTableRefs, }) diff --git a/lib/data/db/tables/game_table.dart b/lib/data/db/tables/game_table.dart index 0fe5a3c..1a37a73 100644 --- a/lib/data/db/tables/game_table.dart +++ b/lib/data/db/tables/game_table.dart @@ -1,11 +1,9 @@ import 'package:drift/drift.dart'; -import 'package:game_tracker/data/db/tables/player_table.dart'; class GameTable extends Table { TextColumn get id => text()(); TextColumn get name => text()(); - TextColumn get winnerId => - text().references(PlayerTable, #id, onDelete: KeyAction.cascade)(); + late final winnerId = text().nullable()(); DateTimeColumn get createdAt => dateTime()(); @override diff --git a/lib/data/dto/game.dart b/lib/data/dto/game.dart index 96e9d73..48ef902 100644 --- a/lib/data/dto/game.dart +++ b/lib/data/dto/game.dart @@ -5,11 +5,11 @@ import 'package:uuid/uuid.dart'; class Game { final String id; + final DateTime createdAt; final String name; final List? players; final Group? group; - final String winner; - final DateTime createdAt; + final Player? winner; Game({ String? id, @@ -17,7 +17,7 @@ class Game { required this.name, this.players, this.group, - this.winner = '', + this.winner, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(); @@ -25,4 +25,27 @@ class Game { String toString() { return 'Game{\n\tid: $id,\n\tname: $name,\n\tplayers: $players,\n\tgroup: $group,\n\twinner: $winner\n}'; } + + /// Creates a Game instance from a JSON object. + Game.fromJson(Map json) + : id = json['id'], + name = json['name'], + createdAt = DateTime.parse(json['createdAt']), + players = json['players'] != null + ? (json['players'] as List) + .map((playerJson) => Player.fromJson(playerJson)) + .toList() + : null, + group = json['group'] != null ? Group.fromJson(json['group']) : null, + winner = json['winner'] != null ? Player.fromJson(json['winner']) : null; + + /// Converts the Game instance to a JSON object. + Map toJson() => { + 'id': id, + 'createdAt': createdAt.toIso8601String(), + 'name': name, + 'players': players?.map((player) => player.toJson()).toList(), + 'group': group?.toJson(), + 'winner': winner?.toJson(), + }; } diff --git a/lib/data/dto/group.dart b/lib/data/dto/group.dart index 46c6f91..92dbd09 100644 --- a/lib/data/dto/group.dart +++ b/lib/data/dto/group.dart @@ -4,9 +4,9 @@ import 'package:uuid/uuid.dart'; class Group { final String id; + final DateTime createdAt; final String name; final List members; - final DateTime createdAt; Group({ String? id, @@ -20,4 +20,21 @@ class Group { String toString() { return 'Group{id: $id, name: $name,members: $members}'; } + + /// Creates a Group instance from a JSON object. + Group.fromJson(Map json) + : id = json['id'], + createdAt = DateTime.parse(json['createdAt']), + name = json['name'], + members = (json['members'] as List) + .map((memberJson) => Player.fromJson(memberJson)) + .toList(); + + /// Converts the Group instance to a JSON object. + Map toJson() => { + 'id': id, + 'createdAt': createdAt.toIso8601String(), + 'name': name, + 'members': members.map((member) => member.toJson()).toList(), + }; } diff --git a/lib/data/dto/player.dart b/lib/data/dto/player.dart index 4ef58b1..cfb4f4b 100644 --- a/lib/data/dto/player.dart +++ b/lib/data/dto/player.dart @@ -3,8 +3,8 @@ import 'package:uuid/uuid.dart'; class Player { final String id; - final String name; final DateTime createdAt; + final String name; Player({String? id, DateTime? createdAt, required this.name}) : id = id ?? const Uuid().v4(), @@ -14,4 +14,17 @@ class Player { String toString() { return 'Player{id: $id,name: $name}'; } + + /// Creates a Player instance from a JSON object. + Player.fromJson(Map json) + : id = json['id'], + createdAt = DateTime.parse(json['createdAt']), + name = json['name']; + + /// Converts the Player instance to a JSON object. + Map toJson() => { + 'id': id, + 'createdAt': createdAt.toIso8601String(), + 'name': name, + }; } diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart index c54369e..59f72ed 100644 --- a/lib/presentation/views/main_menu/create_group_view.dart +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -49,13 +49,16 @@ class _CreateGroupViewState extends State { @override void dispose() { _groupNameController.dispose(); - _searchBarController - .dispose(); // Listener entfernen und Controller aufräumen + _searchBarController.dispose(); super.dispose(); } void loadPlayerList() { - _allPlayersFuture = db.playerDao.getAllPlayers(); + _allPlayersFuture = Future.delayed( + const Duration(milliseconds: 250), + () => db.playerDao.getAllPlayers(), + ); + suggestedPlayers = skeletonData; _allPlayersFuture.then((loadedPlayers) { setState(() { loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); @@ -67,19 +70,19 @@ class _CreateGroupViewState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( backgroundColor: CustomTheme.backgroundColor, - appBar: AppBar( - backgroundColor: CustomTheme.backgroundColor, - scrolledUnderElevation: 0, - title: const Text( - 'Create new group', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - centerTitle: true, + scrolledUnderElevation: 0, + title: const Text( + 'Create new group', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), - body: Column( + centerTitle: true, + ), + body: SafeArea( + child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ Container( @@ -123,12 +126,7 @@ class _CreateGroupViewState extends State { .trim() .isNotEmpty, onTrailingButtonPressed: () async { - addNewPlayerFromSearch( - context: context, - searchBarController: _searchBarController, - db: db, - loadPlayerList: loadPlayerList, - ); + addNewPlayerFromSearch(context: context); }, onChanged: (value) { setState(() { @@ -339,48 +337,47 @@ class _CreateGroupViewState extends State { ), ); } -} -/// Adds a new player to the database from the search bar input. -/// Shows a snackbar indicating success or failure. -/// [context] - BuildContext to show the snackbar. -/// [searchBarController] - TextEditingController of the search bar. -/// [db] - AppDatabase instance to interact with the database. -/// [loadPlayerList] - Function to reload the player list after adding. -void addNewPlayerFromSearch({ - required BuildContext context, - required TextEditingController searchBarController, - required AppDatabase db, - required Function loadPlayerList, -}) async { - String playerName = searchBarController.text.trim(); - bool success = await db.playerDao.addPlayer(player: Player(name: playerName)); - if (!context.mounted) return; - if (success) { - loadPlayerList(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: CustomTheme.boxColor, - content: Center( - child: Text( - 'Successfully added player $playerName.', - style: const TextStyle(color: Colors.white), + /// Adds a new player to the database from the search bar input. + /// Shows a snackbar indicating success or failure. + /// [context] - BuildContext to show the snackbar. + void addNewPlayerFromSearch({required BuildContext context}) async { + String playerName = _searchBarController.text.trim(); + Player createdPlayer = Player(name: playerName); + bool success = await db.playerDao.addPlayer(player: createdPlayer); + if (!context.mounted) return; + if (success) { + selectedPlayers.add(createdPlayer); + allPlayers.add(createdPlayer); + setState(() { + _searchBarController.clear(); + suggestedPlayers = allPlayers.where((player) { + return !selectedPlayers.contains(player); + }).toList(); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: Center( + child: Text( + 'Successfully added player $playerName.', + style: const TextStyle(color: Colors.white), + ), ), ), - ), - ); - searchBarController.clear(); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: CustomTheme.boxColor, - content: Center( - child: Text( - 'Could not add player $playerName.', - style: const TextStyle(color: Colors.white), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: Center( + child: Text( + 'Could not add player $playerName.', + style: const TextStyle(color: Colors.white), + ), ), ), - ), - ); + ); + } } } diff --git a/lib/presentation/views/main_menu/custom_navigation_bar.dart b/lib/presentation/views/main_menu/custom_navigation_bar.dart index 20ced7a..71a072e 100644 --- a/lib/presentation/views/main_menu/custom_navigation_bar.dart +++ b/lib/presentation/views/main_menu/custom_navigation_bar.dart @@ -17,12 +17,7 @@ class CustomNavigationBar extends StatefulWidget { class _CustomNavigationBarState extends State with SingleTickerProviderStateMixin { int currentIndex = 0; - final List tabs = [ - const HomeView(), - const GameHistoryView(), - const GroupsView(), - const StatisticsView(), - ]; + int tabKeyCount = 0; @override void initState() { @@ -31,6 +26,22 @@ class _CustomNavigationBarState extends State @override Widget build(BuildContext context) { + // Pretty ugly but works + final List tabs = [ + KeyedSubtree(key: ValueKey('home_$tabKeyCount'), child: const HomeView()), + KeyedSubtree( + key: ValueKey('games_$tabKeyCount'), + child: const GameHistoryView(), + ), + KeyedSubtree( + key: ValueKey('groups_$tabKeyCount'), + child: const GroupsView(), + ), + KeyedSubtree( + key: ValueKey('stats_$tabKeyCount'), + child: const StatisticsView(), + ), + ]; return Scaffold( appBar: AppBar( centerTitle: true, @@ -42,10 +53,15 @@ class _CustomNavigationBarState extends State scrolledUnderElevation: 0, actions: [ IconButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SettingsView()), - ), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsView()), + ); + setState(() { + tabKeyCount++; + }); + }, icon: const Icon(Icons.settings), ), ], @@ -54,12 +70,14 @@ class _CustomNavigationBarState extends State backgroundColor: CustomTheme.backgroundColor, body: tabs[currentIndex], extendBody: true, - bottomNavigationBar: Padding( - padding: const EdgeInsets.only(left: 12.0, right: 12.0, bottom: 8.0), - child: Material( - elevation: 10, - borderRadius: BorderRadius.circular(24), - color: CustomTheme.primaryColor, + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.only(bottom: 30), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: CustomTheme.primaryColor, + ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: SizedBox( diff --git a/lib/presentation/views/main_menu/groups_view.dart b/lib/presentation/views/main_menu/groups_view.dart index c45cf21..aaef1a5 100644 --- a/lib/presentation/views/main_menu/groups_view.dart +++ b/lib/presentation/views/main_menu/groups_view.dart @@ -34,7 +34,10 @@ class _GroupsViewState extends State { void initState() { super.initState(); db = Provider.of(context, listen: false); - _allGroupsFuture = db.groupDao.getAllGroups(); + _allGroupsFuture = Future.delayed( + const Duration(milliseconds: 250), + () => db.groupDao.getAllGroups(), + ); } @override @@ -69,9 +72,9 @@ class _GroupsViewState extends State { } final bool isLoading = snapshot.connectionState == ConnectionState.waiting; - final List groups = isLoading - ? skeletonData - : (snapshot.data ?? []); + final List groups = + isLoading ? skeletonData : (snapshot.data ?? []) + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return Skeletonizer( effect: PulseEffect( from: Colors.grey[800]!, @@ -93,7 +96,9 @@ class _GroupsViewState extends State { itemCount: groups.length + 1, itemBuilder: (BuildContext context, int index) { if (index == groups.length) { - return const SizedBox(height: 60); + return SizedBox( + height: MediaQuery.paddingOf(context).bottom - 20, + ); } return GroupTile(group: groups[index]); }, @@ -103,7 +108,7 @@ class _GroupsViewState extends State { ), Positioned( - bottom: 80, + bottom: MediaQuery.paddingOf(context).bottom, child: CustomWidthButton( text: 'Create Group', sizeRelativeToWidth: 0.90, diff --git a/lib/presentation/views/main_menu/home_view.dart b/lib/presentation/views/main_menu/home_view.dart index 34e4be3..d9cd1ab 100644 --- a/lib/presentation/views/main_menu/home_view.dart +++ b/lib/presentation/views/main_menu/home_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/data/dto/player.dart'; import 'package:game_tracker/presentation/widgets/buttons/quick_create_button.dart'; import 'package:game_tracker/presentation/widgets/tiles/game_tile.dart'; import 'package:game_tracker/presentation/widgets/tiles/info_tile.dart'; @@ -17,23 +20,42 @@ class HomeView extends StatefulWidget { class _HomeViewState extends State { late Future _gameCountFuture; late Future _groupCountFuture; + late Future> _recentGamesFuture; bool isLoading = true; + late final List skeletonData = List.filled( + 2, + Game( + name: 'Skeleton Game', + group: Group( + name: 'Skeleton Group', + members: [ + Player(name: 'Skeleton Player 1'), + Player(name: 'Skeleton Player 2'), + ], + ), + winner: Player(name: 'Skeleton Player 1'), + ), + ); + @override initState() { super.initState(); final db = Provider.of(context, listen: false); _gameCountFuture = db.gameDao.getGameCount(); _groupCountFuture = db.groupDao.getGroupCount(); + _recentGamesFuture = db.gameDao.getAllGames(); - Future.wait([_gameCountFuture, _groupCountFuture]).then((_) async { - await Future.delayed(const Duration(milliseconds: 50)); - if (mounted) { - setState(() { - isLoading = false; - }); - } - }); + Future.wait([_gameCountFuture, _groupCountFuture, _recentGamesFuture]).then( + (_) async { + await Future.delayed(const Duration(milliseconds: 250)); + if (mounted) { + setState(() { + isLoading = false; + }); + } + }, + ); } @override @@ -48,12 +70,21 @@ class _HomeViewState extends State { ), enabled: isLoading, enableSwitchAnimation: true, - switchAnimationConfig: const SwitchAnimationConfig( - duration: Duration(milliseconds: 200), + switchAnimationConfig: SwitchAnimationConfig( + duration: const Duration(milliseconds: 200), switchInCurve: Curves.linear, switchOutCurve: Curves.linear, transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, - layoutBuilder: AnimatedSwitcher.defaultLayoutBuilder, + layoutBuilder: + (Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, ), child: SingleChildScrollView( child: Column( @@ -103,32 +134,91 @@ class _HomeViewState extends State { width: constraints.maxWidth * 0.95, title: 'Recent Games', icon: Icons.timer, - content: const Padding( - padding: EdgeInsets.symmetric(horizontal: 40.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GameTile( - gameTitle: 'Gamenight', - gameType: 'Cabo', - ruleset: 'Lowest Points', - players: '5 Players', - winner: 'Leonard', - ), - Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Divider(), - ), - GameTile( - gameTitle: 'Schoolbreak', - gameType: 'Uno', - ruleset: 'Highest Points', - players: 'The Gang', - winner: 'Lina', - ), - SizedBox(height: 8), - ], + content: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40.0), + child: FutureBuilder( + future: _recentGamesFuture, + builder: + ( + BuildContext context, + AsyncSnapshot> snapshot, + ) { + if (snapshot.hasError) { + return const Center( + heightFactor: 4, + child: Text( + 'Error while loading recent games.', + ), + ); + } + if (snapshot.connectionState == + ConnectionState.done && + (!snapshot.hasData || + snapshot.data!.isEmpty)) { + return const Center( + heightFactor: 4, + child: Text('No recent games available.'), + ); + } + final List games = + (isLoading + ? skeletonData + : (snapshot.data ?? []) + ..sort( + (a, b) => b.createdAt.compareTo( + a.createdAt, + ), + )) + .take(2) + .toList(); + if (games.isNotEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GameTile( + gameTitle: games[0].name, + gameType: 'Winner', + ruleset: 'Ruleset', + players: _getPlayerText(games[0]), + winner: games[0].winner == null + ? 'Game in progress...' + : games[0].winner!.name, + ), + const Padding( + padding: EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Divider(), + ), + if (games.length > 1) ...[ + GameTile( + gameTitle: games[1].name, + gameType: 'Winner', + ruleset: 'Ruleset', + players: _getPlayerText(games[1]), + winner: games[1].winner == null + ? 'Game in progress...' + : games[1].winner!.name, + ), + const SizedBox(height: 8), + ] else ...[ + const Center( + heightFactor: 4, + child: Text( + 'No second game available.', + ), + ), + ], + ], + ); + } else { + return const Center( + heightFactor: 4, + child: Text('No recent games available.'), + ); + } + }, ), ), ), @@ -189,4 +279,15 @@ class _HomeViewState extends State { }, ); } + + String _getPlayerText(Game game) { + if (game.group == null) { + final playerCount = game.players?.length ?? 0; + return '$playerCount Players'; + } + if (game.players == null || game.players!.isEmpty) { + return game.group!.name; + } + return '${game.group!.name} + ${game.players!.length}'; + } } diff --git a/lib/presentation/views/main_menu/settings_view.dart b/lib/presentation/views/main_menu/settings_view.dart index c3e75f3..6ebb7fb 100644 --- a/lib/presentation/views/main_menu/settings_view.dart +++ b/lib/presentation/views/main_menu/settings_view.dart @@ -1,13 +1,191 @@ import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.dart'; +import 'package:game_tracker/presentation/widgets/tiles/settings_list_tile.dart'; +import 'package:game_tracker/services/data_transfer_service.dart'; -class SettingsView extends StatelessWidget { +class SettingsView extends StatefulWidget { const SettingsView({super.key}); + @override + State createState() => _SettingsViewState(); +} + +class _SettingsViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Einstellungen')), - body: const Center(child: Text('Settings View')), + appBar: AppBar(backgroundColor: CustomTheme.backgroundColor), + backgroundColor: CustomTheme.backgroundColor, + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => + SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 10), + child: Text( + textAlign: TextAlign.start, + 'Menu', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: Text( + textAlign: TextAlign.start, + 'Settings', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + SettingsListTile( + title: 'Export data', + icon: Icons.upload_outlined, + suffixWidget: const Icon(Icons.arrow_forward_ios, size: 16), + onPressed: () async { + final String json = + await DataTransferService.getAppDataAsJson(context); + final result = await DataTransferService.exportData( + json, + 'game_tracker-data', + ); + if (!context.mounted) return; + showExportSnackBar(context: context, result: result); + }, + ), + SettingsListTile( + title: 'Import data', + icon: Icons.download_outlined, + suffixWidget: const Icon(Icons.arrow_forward_ios, size: 16), + onPressed: () async { + final result = await DataTransferService.importData( + context, + ); + if (!context.mounted) return; + showImportSnackBar(context: context, result: result); + }, + ), + SettingsListTile( + title: 'Delete all data', + icon: Icons.download_outlined, + suffixWidget: const Icon(Icons.arrow_forward_ios, size: 16), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete all data?'), + content: const Text('This can\'t be undone'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Löschen'), + ), + ], + ), + ).then((confirmed) { + if (confirmed == true && context.mounted) { + DataTransferService.deleteAllData(context); + showSnackbar( + context: context, + message: 'Daten erfolgreich gelöscht', + ); + } + }); + }, + ), + ], + ), + ), + ), + ); + } + + /// Displays a snackbar based on the import result. + /// + /// [context] The BuildContext to show the snackbar in. + /// [result] The result of the import operation. + void showImportSnackBar({ + required BuildContext context, + required ImportResult result, + }) { + switch (result) { + case ImportResult.success: + showSnackbar(context: context, message: 'Data successfully imported'); + case ImportResult.invalidSchema: + showSnackbar(context: context, message: 'Invalid Schema'); + case ImportResult.fileReadError: + showSnackbar(context: context, message: 'Error reading file'); + case ImportResult.canceled: + showSnackbar(context: context, message: 'Import canceled'); + case ImportResult.formatException: + showSnackbar( + context: context, + message: 'Format Exception (see console)', + ); + case ImportResult.unknownException: + showSnackbar( + context: context, + message: 'Unknown Exception (see console)', + ); + } + } + + /// Displays a snackbar based on the export result. + /// + /// [context] The BuildContext to show the snackbar in. + /// [result] The result of the export operation. + void showExportSnackBar({ + required BuildContext context, + required ExportResult result, + }) { + switch (result) { + case ExportResult.success: + showSnackbar(context: context, message: 'Data successfully exported'); + case ExportResult.canceled: + showSnackbar(context: context, message: 'Export canceled'); + case ExportResult.unknownException: + showSnackbar( + context: context, + message: 'Unknown Exception (see console)', + ); + } + } + + /// Displays a snackbar with the given message and optional action. + /// + /// [context] The BuildContext to show the snackbar in. + /// [message] The message to display in the snackbar. + /// [duration] The duration for which the snackbar is displayed. + /// [action] An optional callback function to execute when the action button is pressed. + void showSnackbar({ + required BuildContext context, + required String message, + Duration duration = const Duration(seconds: 3), + VoidCallback? action, + }) { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(message, style: const TextStyle(color: Colors.white)), + backgroundColor: CustomTheme.onBoxColor, + duration: duration, + action: action != null + ? SnackBarAction(label: 'Rückgängig', onPressed: action) + : null, + ), ); } } diff --git a/lib/presentation/views/main_menu/statistics_view.dart b/lib/presentation/views/main_menu/statistics_view.dart index 84ccf77..6107586 100644 --- a/lib/presentation/views/main_menu/statistics_view.dart +++ b/lib/presentation/views/main_menu/statistics_view.dart @@ -1,10 +1,262 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/data/dto/player.dart'; +import 'package:game_tracker/presentation/widgets/tiles/statistics_tile.dart'; +import 'package:provider/provider.dart'; +import 'package:skeletonizer/skeletonizer.dart'; -class StatisticsView extends StatelessWidget { +class StatisticsView extends StatefulWidget { const StatisticsView({super.key}); + @override + State createState() => _StatisticsViewState(); +} + +class _StatisticsViewState extends State { + late Future> _gamesFuture; + late Future> _playersFuture; + List<(String, int)> winCounts = List.filled(6, ('Skeleton Player', 1)); + List<(String, int)> gameCounts = List.filled(6, ('Skeleton Player', 1)); + List<(String, double)> winRates = List.filled(6, ('Skeleton Player', 1)); + bool isLoading = true; + + @override + void initState() { + super.initState(); + final db = Provider.of(context, listen: false); + _gamesFuture = db.gameDao.getAllGames(); + _playersFuture = db.playerDao.getAllPlayers(); + + Future.wait([_gamesFuture, _playersFuture]).then((results) async { + await Future.delayed(const Duration(milliseconds: 250)); + final games = results[0] as List; + final players = results[1] as List; + winCounts = _calculateWinsForAllPlayers(games, players); + gameCounts = _calculateGameAmountsForAllPlayers(games, players); + winRates = computeWinRatePercent(wins: winCounts, games: gameCounts); + if (mounted) { + setState(() { + isLoading = false; + }); + } + }); + } + @override Widget build(BuildContext context) { - return const Center(child: Text('Statistics View')); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SingleChildScrollView( + child: Skeletonizer( + effect: PulseEffect( + from: Colors.grey[800]!, + to: Colors.grey[600]!, + duration: const Duration(milliseconds: 800), + ), + enabled: isLoading, + enableSwitchAnimation: true, + switchAnimationConfig: SwitchAnimationConfig( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.linear, + switchOutCurve: Curves.linear, + transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, + layoutBuilder: + (Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: constraints.maxHeight * 0.01), + StatisticsTile( + icon: Icons.sports_score, + title: 'Wins per Player', + width: constraints.maxWidth * 0.95, + values: winCounts, + itemCount: 3, + barColor: Colors.blue, + ), + SizedBox(height: constraints.maxHeight * 0.02), + StatisticsTile( + icon: Icons.percent, + title: 'Winrate per Player', + width: constraints.maxWidth * 0.95, + values: winRates, + itemCount: 5, + barColor: Colors.orange[700]!, + ), + SizedBox(height: constraints.maxHeight * 0.02), + StatisticsTile( + icon: Icons.casino, + title: 'Games per Player', + width: constraints.maxWidth * 0.95, + values: gameCounts, + itemCount: 10, + barColor: Colors.green, + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom), + ], + ), + ), + ), + ); + }, + ); + } + + /// Calculates the number of wins for each player + /// and returns a sorted list of tuples (playerName, winCount) + List<(String, int)> _calculateWinsForAllPlayers( + List games, + List players, + ) { + List<(String, int)> winCounts = []; + + // Getting the winners + for (var game in games) { + final winner = game.winner; + if (winner != null) { + final index = winCounts.indexWhere((entry) => entry.$1 == winner.id); + // -1 means winner not found in winCounts + if (index != -1) { + final current = winCounts[index].$2; + winCounts[index] = (winner.id, current + 1); + } else { + winCounts.add((winner.id, 1)); + } + } + } + + // Adding all players with zero wins + for (var player in players) { + final index = winCounts.indexWhere((entry) => entry.$1 == player.id); + // -1 means player not found in winCounts + if (index == -1) { + winCounts.add((player.id, 0)); + } + } + + // Replace player IDs with names + for (int i = 0; i < winCounts.length; i++) { + final playerId = winCounts[i].$1; + final player = players.firstWhere( + (p) => p.id == playerId, + orElse: () => Player(id: playerId, name: 'N.a.'), + ); + winCounts[i] = (player.name, winCounts[i].$2); + } + + winCounts.sort((a, b) => b.$2.compareTo(a.$2)); + + return winCounts; + } + + /// Calculates the number of games played for each player + /// and returns a sorted list of tuples (playerName, gameCount) + List<(String, int)> _calculateGameAmountsForAllPlayers( + List games, + List players, + ) { + List<(String, int)> gameCounts = []; + + // Counting games for each player + for (var game in games) { + if (game.group != null) { + final members = game.group!.members.map((p) => p.id).toList(); + for (var playerId in members) { + final index = gameCounts.indexWhere((entry) => entry.$1 == playerId); + // -1 means player not found in gameCounts + if (index != -1) { + final current = gameCounts[index].$2; + gameCounts[index] = (playerId, current + 1); + } else { + gameCounts.add((playerId, 1)); + } + } + } + if (game.players != null) { + final members = game.players!.map((p) => p.id).toList(); + for (var playerId in members) { + final index = gameCounts.indexWhere((entry) => entry.$1 == playerId); + // -1 means player not found in gameCounts + if (index != -1) { + final current = gameCounts[index].$2; + gameCounts[index] = (playerId, current + 1); + } else { + gameCounts.add((playerId, 1)); + } + } + } + } + + // Adding all players with zero games + for (var player in players) { + final index = gameCounts.indexWhere((entry) => entry.$1 == player.id); + // -1 means player not found in gameCounts + if (index == -1) { + gameCounts.add((player.id, 0)); + } + } + + // Replace player IDs with names + for (int i = 0; i < gameCounts.length; i++) { + final playerId = gameCounts[i].$1; + final player = players.firstWhere( + (p) => p.id == playerId, + orElse: () => Player(id: playerId, name: 'N.a.'), + ); + gameCounts[i] = (player.name, gameCounts[i].$2); + } + + gameCounts.sort((a, b) => b.$2.compareTo(a.$2)); + + return gameCounts; + } + + // dart + List<(String, double)> computeWinRatePercent({ + required List<(String, int)> wins, + required List<(String, int)> games, + }) { + final Map winsMap = {for (var e in wins) e.$1: e.$2}; + final Map gamesMap = {for (var e in games) e.$1: e.$2}; + + // Get all unique player names + final names = {...winsMap.keys, ...gamesMap.keys}; + + // Calculate win rates + final result = names.map((name) { + final int w = winsMap[name] ?? 0; + final int g = gamesMap[name] ?? 0; + // Calculate percentage and round to 2 decimal places + // Avoid division by zero + final double percent = (g > 0) + ? double.parse(((w / g)).toStringAsFixed(2)) + : 0; + return (name, percent); + }).toList(); + + // Sort the result: first by winrate descending, + // then by wins descending in case of a tie + result.sort((a, b) { + final cmp = b.$2.compareTo(a.$2); + if (cmp != 0) return cmp; + final wa = winsMap[a.$1] ?? 0; + final wb = winsMap[b.$1] ?? 0; + return wb.compareTo(wa); + }); + + return result; } } diff --git a/lib/presentation/widgets/tiles/settings_list_tile.dart b/lib/presentation/widgets/tiles/settings_list_tile.dart new file mode 100644 index 0000000..d5c421f --- /dev/null +++ b/lib/presentation/widgets/tiles/settings_list_tile.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class SettingsListTile extends StatelessWidget { + final VoidCallback? onPressed; + final IconData icon; + final String title; + final Widget? suffixWidget; + const SettingsListTile({ + super.key, + required this.title, + required this.icon, + this.suffixWidget, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.95, + child: GestureDetector( + onTap: onPressed ?? () {}, + child: Container( + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: CustomTheme.primaryColor, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 24), + ), + const SizedBox(width: 16), + Text(title, style: const TextStyle(fontSize: 18)), + ], + ), + if (suffixWidget != null) + suffixWidget! + else + const SizedBox.shrink(), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/statistics_tile.dart b/lib/presentation/widgets/tiles/statistics_tile.dart new file mode 100644 index 0000000..6e3b9b2 --- /dev/null +++ b/lib/presentation/widgets/tiles/statistics_tile.dart @@ -0,0 +1,102 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:game_tracker/presentation/widgets/tiles/info_tile.dart'; + +class StatisticsTile extends StatelessWidget { + const StatisticsTile({ + super.key, + required this.icon, + required this.title, + required this.width, + required this.values, + required this.itemCount, + required this.barColor, + }); + + final IconData icon; + final String title; + final double width; + final List<(String, num)> values; + final int itemCount; + final Color barColor; + + @override + Widget build(BuildContext context) { + final maxBarWidth = MediaQuery.of(context).size.width * 0.65; + + return InfoTile( + width: width, + title: title, + icon: icon, + content: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Visibility( + visible: values.isNotEmpty, + replacement: const Center( + heightFactor: 4, + child: Text('No data available.'), + ), + child: Column( + children: List.generate(min(values.length, itemCount), (index) { + /// The maximum wins among all players + final maxGames = values.isNotEmpty ? values[0].$2 : 0; + + /// Fraction of wins + final double fraction = (maxGames > 0) + ? (values[index].$2 / maxGames) + : 0.0; + + /// Calculated width for current the bar + final double barWidth = maxBarWidth * fraction; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + height: 24, + width: barWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: barColor, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + values[index].$1, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const Spacer(), + Center( + child: Text( + values[index].$2 <= 1 && values[index].$2 is double + ? values[index].$2.toStringAsFixed(2) + : values[index].$2.toString(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + }), + ), + ), + ), + ); + } +} diff --git a/lib/services/data_transfer_service.dart b/lib/services/data_transfer_service.dart new file mode 100644 index 0000000..eaa9633 --- /dev/null +++ b/lib/services/data_transfer_service.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:game_tracker/core/enums.dart'; +import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/data/dto/player.dart'; +import 'package:json_schema/json_schema.dart'; +import 'package:provider/provider.dart'; + +class DataTransferService { + /// Deletes all data from the database. + static Future deleteAllData(BuildContext context) async { + final db = Provider.of(context, listen: false); + await db.gameDao.deleteAllGames(); + await db.groupDao.deleteAllGroups(); + await db.playerDao.deleteAllPlayers(); + } + + /// Retrieves all application data and converts it to a JSON string. + /// Returns the JSON string representation of the data. + static Future getAppDataAsJson(BuildContext context) async { + final db = Provider.of(context, listen: false); + final games = await db.gameDao.getAllGames(); + final groups = await db.groupDao.getAllGroups(); + final players = await db.playerDao.getAllPlayers(); + + // Construct a JSON representation of the data + final Map jsonMap = { + 'games': games.map((game) => game.toJson()).toList(), + 'groups': groups.map((group) => group.toJson()).toList(), + 'players': players.map((player) => player.toJson()).toList(), + }; + + return json.encode(jsonMap); + } + + /// Exports the given JSON string to a file with the specified name. + /// Returns an [ExportResult] indicating the outcome. + /// + /// [jsonString] The JSON string to be exported. + /// [fileName] The desired name for the exported file (without extension). + static Future exportData( + String jsonString, + String fileName, + ) async { + try { + final bytes = Uint8List.fromList(utf8.encode(jsonString)); + final path = await FilePicker.platform.saveFile( + fileName: '$fileName.json', + bytes: bytes, + ); + if (path == null) { + return ExportResult.canceled; + } else { + return ExportResult.success; + } + } catch (e, stack) { + print('[exportData] $e'); + print(stack); + return ExportResult.unknownException; + } + } + + /// Imports data from a selected JSON file into the database. + static Future importData(BuildContext context) async { + final db = Provider.of(context, listen: false); + + final path = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (path == null) { + return ImportResult.canceled; + } + + try { + final jsonString = await _readFileContent(path.files.single); + if (jsonString == null) { + return ImportResult.fileReadError; + } + + if (await _validateJsonSchema(jsonString)) { + final Map jsonData = + json.decode(jsonString) as Map; + + final List? gamesJson = jsonData['games'] as List?; + final List? groupsJson = jsonData['groups'] as List?; + final List? playersJson = + jsonData['players'] as List?; + + final List importedGames = + gamesJson + ?.map((g) => Game.fromJson(g as Map)) + .toList() ?? + []; + final List importedGroups = + groupsJson + ?.map((g) => Group.fromJson(g as Map)) + .toList() ?? + []; + final List importedPlayers = + playersJson + ?.map((p) => Player.fromJson(p as Map)) + .toList() ?? + []; + + await db.playerDao.addPlayers(players: importedPlayers); + await db.groupDao.addGroups(groups: importedGroups); + await db.gameDao.addGames(games: importedGames); + } else { + return ImportResult.invalidSchema; + } + return ImportResult.success; + } on FormatException catch (e, stack) { + print('[importData] FormatException'); + print('[importData] $e'); + print(stack); + return ImportResult.formatException; + } on Exception catch (e, stack) { + print('[importData] Exception'); + print('[importData] $e'); + print(stack); + return ImportResult.unknownException; + } + } + + /// Helper method to read file content from either bytes or path + static Future _readFileContent(PlatformFile file) async { + if (file.bytes != null) return utf8.decode(file.bytes!); + if (file.path != null) return await File(file.path!).readAsString(); + return null; + } + + /// Validates the given JSON string against the predefined schema. + static Future _validateJsonSchema(String jsonString) async { + final String schemaString; + + schemaString = await rootBundle.loadString('assets/schema.json'); + + try { + final schema = JsonSchema.create(json.decode(schemaString)); + final jsonData = json.decode(jsonString); + final result = schema.validate(jsonData); + + if (result.isValid) { + return true; + } + return false; + } catch (e, stack) { + print('[validateJsonSchema] $e'); + print(stack); + return false; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index b17f409..fa4c213 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,9 @@ dependencies: provider: ^6.1.5 skeletonizer: ^2.1.0+1 uuid: ^4.5.2 + file_picker: ^10.3.6 + json_schema: ^5.2.2 + file_saver: ^0.3.1 clock: ^1.1.2 dev_dependencies: @@ -31,3 +34,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/schema.json diff --git a/test/db_tests/game_test.dart b/test/db_tests/game_test.dart index d726425..4cf6982 100644 --- a/test/db_tests/game_test.dart +++ b/test/db_tests/game_test.dart @@ -9,13 +9,17 @@ import 'package:game_tracker/data/dto/player.dart'; void main() { late AppDatabase database; - late Player player1; - late Player player2; - late Player player3; - late Player player4; - late Player player5; - late Group testgroup; - late Game testgame; + late Player testPlayer1; + late Player testPlayer2; + late Player testPlayer3; + late Player testPlayer4; + late Player testPlayer5; + late Group testGroup1; + late Group testGroup2; + late Game testGame1; + late Game testGame2; + late Game testGameOnlyPlayers; + late Game testGameOnlyGroup; final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); final fakeClock = Clock(() => fixedDate); @@ -29,88 +33,206 @@ void main() { ); withClock(fakeClock, () { - player1 = Player(name: 'Alice'); - player2 = Player(name: 'Bob'); - player3 = Player(name: 'Charlie'); - player4 = Player(name: 'Diana'); - player5 = Player(name: 'Eve'); - testgroup = Group( - name: 'Test Group', - members: [player1, player2, player3], + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testPlayer5 = Player(name: 'Eve'); + testGroup1 = Group( + name: 'Test Group 2', + members: [testPlayer1, testPlayer2, testPlayer3], ); - testgame = Game( - name: 'Test Game', - group: testgroup, - players: [player4, player5], + testGroup2 = Group( + name: 'Test Group 2', + members: [testPlayer4, testPlayer5], ); + testGame1 = Game( + name: 'First Test Game', + group: testGroup1, + players: [testPlayer4, testPlayer5], + winner: testPlayer4, + ); + testGame2 = Game( + name: 'Second Test Game', + group: testGroup2, + players: [testPlayer1, testPlayer2, testPlayer3], + winner: testPlayer2, + ); + testGameOnlyPlayers = Game( + name: 'Test Game with Players', + players: [testPlayer1, testPlayer2, testPlayer3], + winner: testPlayer3, + ); + testGameOnlyGroup = Game(name: 'Test Game with Group', group: testGroup2); }); }); tearDown(() async { await database.close(); }); - group('game tests', () { - test('game is added correctly', () async { - await database.gameDao.addGame(game: testgame); + group('Game Tests', () { + test('Adding and fetching single game works correctly', () async { + await database.gameDao.addGame(game: testGame1); - final result = await database.gameDao.getGameById(gameId: testgame.id); + final result = await database.gameDao.getGameById(gameId: testGame1.id); - expect(result.id, testgame.id); - expect(result.name, testgame.name); - expect(result.winner, testgame.winner); - expect(result.createdAt, testgame.createdAt); + expect(result.id, testGame1.id); + expect(result.name, testGame1.name); + expect(result.createdAt, testGame1.createdAt); + + if (result.winner != null && testGame1.winner != null) { + expect(result.winner!.id, testGame1.winner!.id); + expect(result.winner!.name, testGame1.winner!.name); + expect(result.winner!.createdAt, testGame1.winner!.createdAt); + } else { + expect(result.winner, testGame1.winner); + } if (result.group != null) { - expect(result.group!.members.length, testgroup.members.length); + expect(result.group!.members.length, testGroup1.members.length); - for (int i = 0; i < testgroup.members.length; i++) { - expect(result.group!.members[i].id, testgroup.members[i].id); - expect(result.group!.members[i].name, testgroup.members[i].name); + for (int i = 0; i < testGroup1.members.length; i++) { + expect(result.group!.members[i].id, testGroup1.members[i].id); + expect(result.group!.members[i].name, testGroup1.members[i].name); } } else { fail('Group is null'); } if (result.players != null) { - expect(result.players!.length, testgame.players!.length); + expect(result.players!.length, testGame1.players!.length); - for (int i = 0; i < testgame.players!.length; i++) { - expect(result.players![i].id, testgame.players![i].id); - expect(result.players![i].name, testgame.players![i].name); - expect(result.players![i].createdAt, testgame.players![i].createdAt); + for (int i = 0; i < testGame1.players!.length; i++) { + expect(result.players![i].id, testGame1.players![i].id); + expect(result.players![i].name, testGame1.players![i].name); + expect(result.players![i].createdAt, testGame1.players![i].createdAt); } } else { fail('Players is null'); } }); - test('game is deleted correctly', () async { - await database.gameDao.addGame(game: testgame); + test('Adding and fetching multiple games works correctly', () async { + await database.gameDao.addGames( + games: [testGame1, testGame2, testGameOnlyGroup, testGameOnlyPlayers], + ); + + final allGames = await database.gameDao.getAllGames(); + expect(allGames.length, 4); + + final testGames = { + testGame1.id: testGame1, + testGame2.id: testGame2, + testGameOnlyGroup.id: testGameOnlyGroup, + testGameOnlyPlayers.id: testGameOnlyPlayers, + }; + + for (final game in allGames) { + final testGame = testGames[game.id]!; + + // Game-Checks + expect(game.id, testGame.id); + expect(game.name, testGame.name); + expect(game.createdAt, testGame.createdAt); + if (game.winner != null && testGame.winner != null) { + expect(game.winner!.id, testGame.winner!.id); + expect(game.winner!.name, testGame.winner!.name); + expect(game.winner!.createdAt, testGame.winner!.createdAt); + } else { + expect(game.winner, testGame.winner); + } + + // Group-Checks + if (testGame.group != null) { + expect(game.group!.id, testGame.group!.id); + expect(game.group!.name, testGame.group!.name); + expect(game.group!.createdAt, testGame.group!.createdAt); + + // Group Members-Checks + expect(game.group!.members.length, testGame.group!.members.length); + for (int i = 0; i < testGame.group!.members.length; i++) { + expect(game.group!.members[i].id, testGame.group!.members[i].id); + expect( + game.group!.members[i].name, + testGame.group!.members[i].name, + ); + expect( + game.group!.members[i].createdAt, + testGame.group!.members[i].createdAt, + ); + } + } else { + expect(game.group, null); + } + + // Players-Checks + if (testGame.players != null) { + expect(game.players!.length, testGame.players!.length); + for (int i = 0; i < testGame.players!.length; i++) { + expect(game.players![i].id, testGame.players![i].id); + expect(game.players![i].name, testGame.players![i].name); + expect(game.players![i].createdAt, testGame.players![i].createdAt); + } + } else { + expect(game.players, null); + } + } + }); + + test('Adding the same game twice does not create duplicates', () async { + await database.gameDao.addGame(game: testGame1); + await database.gameDao.addGame(game: testGame1); + + final gameCount = await database.gameDao.getGameCount(); + expect(gameCount, 1); + }); + + test('Game existence check works correctly', () async { + var gameExists = await database.gameDao.gameExists(gameId: testGame1.id); + expect(gameExists, false); + + await database.gameDao.addGame(game: testGame1); + + gameExists = await database.gameDao.gameExists(gameId: testGame1.id); + expect(gameExists, true); + }); + + test('Deleting a game works correctly', () async { + await database.gameDao.addGame(game: testGame1); final gameDeleted = await database.gameDao.deleteGame( - gameId: testgame.id, + gameId: testGame1.id, ); expect(gameDeleted, true); - final gameExists = await database.gameDao.gameExists(gameId: testgame.id); + final gameExists = await database.gameDao.gameExists( + gameId: testGame1.id, + ); expect(gameExists, false); }); - test('get game count works correctly', () async { - final initialCount = await database.gameDao.getGameCount(); - expect(initialCount, 0); + test('Getting the game count works correctly', () async { + var gameCount = await database.gameDao.getGameCount(); + expect(gameCount, 0); - await database.gameDao.addGame(game: testgame); + await database.gameDao.addGame(game: testGame1); - final gameAdded = await database.gameDao.getGameCount(); - expect(gameAdded, 1); + gameCount = await database.gameDao.getGameCount(); + expect(gameCount, 1); - final gameRemoved = await database.gameDao.deleteGame( - gameId: testgame.id, - ); - expect(gameRemoved, true); + await database.gameDao.addGame(game: testGame2); - final finalCount = await database.gameDao.getGameCount(); - expect(finalCount, 0); + gameCount = await database.gameDao.getGameCount(); + expect(gameCount, 2); + + await database.gameDao.deleteGame(gameId: testGame1.id); + + gameCount = await database.gameDao.getGameCount(); + expect(gameCount, 1); + + await database.gameDao.deleteGame(gameId: testGame2.id); + + gameCount = await database.gameDao.getGameCount(); + expect(gameCount, 0); }); }); } diff --git a/test/db_tests/group_game_test.dart b/test/db_tests/group_game_test.dart new file mode 100644 index 0000000..1733243 --- /dev/null +++ b/test/db_tests/group_game_test.dart @@ -0,0 +1,134 @@ +import 'package:clock/clock.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/data/dto/player.dart'; + +void main() { + late AppDatabase database; + late Player testPlayer1; + late Player testPlayer2; + late Player testPlayer3; + late Player testPlayer4; + late Player testPlayer5; + late Group testgroup; + late Game testgameWithGroup; + late Game testgameWithPlayers; + final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); + final fakeClock = Clock(() => fixedDate); + + setUp(() { + database = AppDatabase( + DatabaseConnection( + NativeDatabase.memory(), + // Recommended for widget tests to avoid test errors. + closeStreamsSynchronously: true, + ), + ); + + withClock(fakeClock, () { + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testPlayer5 = Player(name: 'Eve'); + testgroup = Group( + name: 'Test Group', + members: [testPlayer1, testPlayer2, testPlayer3], + ); + testgameWithPlayers = Game( + name: 'Test Game with Players', + players: [testPlayer4, testPlayer5], + ); + testgameWithGroup = Game(name: 'Test Game with Group', group: testgroup); + }); + }); + tearDown(() async { + await database.close(); + }); + group('Group-Game Tests', () { + test('Game has group works correctly', () async { + await database.gameDao.addGame(game: testgameWithPlayers); + await database.groupDao.addGroup(group: testgroup); + + var gameHasGroup = await database.groupGameDao.gameHasGroup( + gameId: testgameWithPlayers.id, + ); + + expect(gameHasGroup, false); + + await database.groupGameDao.addGroupToGame( + testgameWithPlayers.id, + testgroup.id, + ); + + gameHasGroup = await database.groupGameDao.gameHasGroup( + gameId: testgameWithPlayers.id, + ); + + expect(gameHasGroup, true); + }); + + test('Adding a group to a game works correctly', () async { + await database.gameDao.addGame(game: testgameWithPlayers); + await database.groupDao.addGroup(group: testgroup); + await database.groupGameDao.addGroupToGame( + testgameWithPlayers.id, + testgroup.id, + ); + + var groupAdded = await database.groupGameDao.isGroupInGame( + gameId: testgameWithPlayers.id, + groupId: testgroup.id, + ); + expect(groupAdded, true); + + groupAdded = await database.groupGameDao.isGroupInGame( + gameId: testgameWithPlayers.id, + groupId: '', + ); + expect(groupAdded, false); + }); + + test('Removing group from game works correctly', () async { + await database.gameDao.addGame(game: testgameWithGroup); + + final groupToRemove = testgameWithGroup.group!; + + final removed = await database.groupGameDao.removeGroupFromGame( + groupId: groupToRemove.id, + gameId: testgameWithGroup.id, + ); + expect(removed, true); + + final result = await database.gameDao.getGameById( + gameId: testgameWithGroup.id, + ); + expect(result.group, null); + }); + + test('Retrieving group of a game works correctly', () async { + await database.gameDao.addGame(game: testgameWithGroup); + final group = await database.groupGameDao.getGroupOfGame( + gameId: testgameWithGroup.id, + ); + + if (group == null) { + fail('Group should not be null'); + } + + expect(group.id, testgroup.id); + expect(group.name, testgroup.name); + expect(group.createdAt, testgroup.createdAt); + expect(group.members.length, testgroup.members.length); + for (int i = 0; i < group.members.length; i++) { + expect(group.members[i].id, testgroup.members[i].id); + expect(group.members[i].name, testgroup.members[i].name); + expect(group.members[i].createdAt, testgroup.members[i].createdAt); + } + }); + }); +} diff --git a/test/db_tests/group_test.dart b/test/db_tests/group_test.dart index a076ab0..a1832c1 100644 --- a/test/db_tests/group_test.dart +++ b/test/db_tests/group_test.dart @@ -8,12 +8,14 @@ import 'package:game_tracker/data/dto/player.dart'; void main() { late AppDatabase database; - late Player player1; - late Player player2; - late Player player3; - late Player player4; - late Group testgroup; - late Group testgroup2; + late Player testPlayer1; + late Player testPlayer2; + late Player testPlayer3; + late Player testPlayer4; + late Group testGroup1; + late Group testGroup2; + late Group testGroup3; + late Group testGroup4; final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); final fakeClock = Clock(() => fixedDate); @@ -27,157 +29,144 @@ void main() { ); withClock(fakeClock, () { - player1 = Player(name: 'Alice'); - player2 = Player(name: 'Bob'); - player3 = Player(name: 'Charlie'); - player4 = Player(name: 'Diana'); - testgroup = Group( + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testGroup1 = Group( name: 'Test Group', - members: [player1, player2, player3], + members: [testPlayer1, testPlayer2, testPlayer3], ); - testgroup2 = Group( + testGroup2 = Group( id: 'gr2', name: 'Second Group', - members: [player2, player3, player4], + members: [testPlayer2, testPlayer3, testPlayer4], + ); + testGroup3 = Group( + id: 'gr2', + name: 'Second Group', + members: [testPlayer2, testPlayer4], + ); + testGroup4 = Group( + id: 'gr2', + name: 'Second Group', + members: [testPlayer1, testPlayer2, testPlayer3, testPlayer4], ); }); }); tearDown(() async { await database.close(); }); - group('group tests', () { - test('all groups get fetched correctly', () async { - await database.groupDao.addGroup(group: testgroup); - await database.groupDao.addGroup(group: testgroup2); + group('Group Tests', () { + test('Adding and fetching a single group works correctly', () async { + await database.groupDao.addGroup(group: testGroup1); + + final fetchedGroup = await database.groupDao.getGroupById( + groupId: testGroup1.id, + ); + + expect(fetchedGroup.id, testGroup1.id); + expect(fetchedGroup.name, testGroup1.name); + expect(fetchedGroup.createdAt, testGroup1.createdAt); + + expect(fetchedGroup.members.length, testGroup1.members.length); + for (int i = 0; i < testGroup1.members.length; i++) { + expect(fetchedGroup.members[i].id, testGroup1.members[i].id); + expect(fetchedGroup.members[i].name, testGroup1.members[i].name); + expect( + fetchedGroup.members[i].createdAt, + testGroup1.members[i].createdAt, + ); + } + }); + + test('Adding and fetching multiple groups works correctly', () async { + await database.groupDao.addGroups( + groups: [testGroup1, testGroup2, testGroup3, testGroup4], + ); final allGroups = await database.groupDao.getAllGroups(); expect(allGroups.length, 2); - final fetchedGroup1 = allGroups.firstWhere((g) => g.id == testgroup.id); - expect(fetchedGroup1.name, testgroup.name); - expect(fetchedGroup1.members.length, testgroup.members.length); - expect(fetchedGroup1.members.elementAt(0).id, player1.id); - expect(fetchedGroup1.members.elementAt(0).createdAt, player1.createdAt); + final testGroups = {testGroup1.id: testGroup1, testGroup2.id: testGroup2}; - final fetchedGroup2 = allGroups.firstWhere((g) => g.id == testgroup2.id); - expect(fetchedGroup2.name, testgroup2.name); - expect(fetchedGroup2.members.length, testgroup2.members.length); - expect(fetchedGroup2.members.elementAt(0).id, player2.id); - expect(fetchedGroup2.members.elementAt(0).createdAt, player2.createdAt); - }); + for (final group in allGroups) { + final testGroup = testGroups[group.id]!; - test('group and group members gets added correctly', () async { - await database.groupDao.addGroup(group: testgroup); + expect(group.id, testGroup.id); + expect(group.name, testGroup.name); + expect(group.createdAt, testGroup.createdAt); - final result = await database.groupDao.getGroupById( - groupId: testgroup.id, - ); - - expect(result.id, testgroup.id); - expect(result.name, testgroup.name); - expect(result.createdAt, testgroup.createdAt); - - expect(result.members.length, testgroup.members.length); - for (int i = 0; i < testgroup.members.length; i++) { - expect(result.members[i].id, testgroup.members[i].id); - expect(result.members[i].name, testgroup.members[i].name); - expect(result.members[i].createdAt, testgroup.members[i].createdAt); + expect(group.members.length, testGroup.members.length); + for (int i = 0; i < testGroup.members.length; i++) { + expect(group.members[i].id, testGroup.members[i].id); + expect(group.members[i].name, testGroup.members[i].name); + expect(group.members[i].createdAt, testGroup.members[i].createdAt); + } } }); - test('group gets deleted correctly', () async { - await database.groupDao.addGroup(group: testgroup); + test('Adding the same group twice does not create duplicates', () async { + await database.groupDao.addGroup(group: testGroup1); + await database.groupDao.addGroup(group: testGroup1); + + final allGroups = await database.groupDao.getAllGroups(); + expect(allGroups.length, 1); + }); + + test('Group existence check works correctly', () async { + var groupExists = await database.groupDao.groupExists( + groupId: testGroup1.id, + ); + expect(groupExists, false); + + await database.groupDao.addGroup(group: testGroup1); + + groupExists = await database.groupDao.groupExists(groupId: testGroup1.id); + expect(groupExists, true); + }); + + test('Deleting a group works correctly', () async { + await database.groupDao.addGroup(group: testGroup1); final groupDeleted = await database.groupDao.deleteGroup( - groupId: testgroup.id, + groupId: testGroup1.id, ); expect(groupDeleted, true); final groupExists = await database.groupDao.groupExists( - groupId: testgroup.id, + groupId: testGroup1.id, ); expect(groupExists, false); }); - test('group name gets updated correcly ', () async { - await database.groupDao.addGroup(group: testgroup); + test('Updating a group name works correcly', () async { + await database.groupDao.addGroup(group: testGroup1); const newGroupName = 'new group name'; await database.groupDao.updateGroupname( - groupId: testgroup.id, + groupId: testGroup1.id, newName: newGroupName, ); final result = await database.groupDao.getGroupById( - groupId: testgroup.id, + groupId: testGroup1.id, ); expect(result.name, newGroupName); }); - test('Adding player to group works correctly', () async { - await database.groupDao.addGroup(group: testgroup); - - await database.playerGroupDao.addPlayerToGroup( - player: player4, - groupId: testgroup.id, - ); - - final playerAdded = await database.playerGroupDao.isPlayerInGroup( - playerId: player4.id, - groupId: testgroup.id, - ); - - expect(playerAdded, true); - - final playerNotAdded = !await database.playerGroupDao.isPlayerInGroup( - playerId: '', - groupId: testgroup.id, - ); - - expect(playerNotAdded, true); - - final result = await database.groupDao.getGroupById( - groupId: testgroup.id, - ); - expect(result.members.length, testgroup.members.length + 1); - - final addedPlayer = result.members.firstWhere((p) => p.id == player4.id); - expect(addedPlayer.name, player4.name); - expect(addedPlayer.createdAt, player4.createdAt); - }); - - test('Removing player from group works correctly', () async { - await database.groupDao.addGroup(group: testgroup); - - final playerToRemove = testgroup.members[0]; - - final removed = await database.playerGroupDao.removePlayerFromGroup( - playerId: playerToRemove.id, - groupId: testgroup.id, - ); - expect(removed, true); - - final result = await database.groupDao.getGroupById( - groupId: testgroup.id, - ); - expect(result.members.length, testgroup.members.length - 1); - - final playerExists = result.members.any((p) => p.id == playerToRemove.id); - expect(playerExists, false); - }); - - test('get group count works correctly', () async { + test('Getting the group count works correctly', () async { final initialCount = await database.groupDao.getGroupCount(); expect(initialCount, 0); - await database.groupDao.addGroup(group: testgroup); + await database.groupDao.addGroup(group: testGroup1); final groupAdded = await database.groupDao.getGroupCount(); expect(groupAdded, 1); final groupRemoved = await database.groupDao.deleteGroup( - groupId: testgroup.id, + groupId: testGroup1.id, ); expect(groupRemoved, true); diff --git a/test/db_tests/player_game_test.dart b/test/db_tests/player_game_test.dart new file mode 100644 index 0000000..e8fd707 --- /dev/null +++ b/test/db_tests/player_game_test.dart @@ -0,0 +1,140 @@ +import 'package:clock/clock.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/game.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/data/dto/player.dart'; + +void main() { + late AppDatabase database; + late Player testPlayer1; + late Player testPlayer2; + late Player testPlayer3; + late Player testPlayer4; + late Player testPlayer5; + late Player testPlayer6; + late Group testgroup; + late Game testGameOnlyGroup; + late Game testGameOnlyPlayers; + final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); + final fakeClock = Clock(() => fixedDate); + + setUp(() { + database = AppDatabase( + DatabaseConnection( + NativeDatabase.memory(), + // Recommended for widget tests to avoid test errors. + closeStreamsSynchronously: true, + ), + ); + + withClock(fakeClock, () { + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testPlayer5 = Player(name: 'Eve'); + testPlayer6 = Player(name: 'Frank'); + testgroup = Group( + name: 'Test Group', + members: [testPlayer1, testPlayer2, testPlayer3], + ); + testGameOnlyGroup = Game(name: 'Test Game with Group', group: testgroup); + testGameOnlyPlayers = Game( + name: 'Test Game with Players', + players: [testPlayer4, testPlayer5, testPlayer6], + ); + }); + }); + tearDown(() async { + await database.close(); + }); + + group('Player-Game Tests', () { + test('Game has player works correctly', () async { + await database.gameDao.addGame(game: testGameOnlyGroup); + await database.playerDao.addPlayer(player: testPlayer1); + + var gameHasPlayers = await database.playerGameDao.gameHasPlayers( + gameId: testGameOnlyGroup.id, + ); + + expect(gameHasPlayers, false); + + await database.playerGameDao.addPlayerToGame( + gameId: testGameOnlyGroup.id, + playerId: testPlayer1.id, + ); + + gameHasPlayers = await database.playerGameDao.gameHasPlayers( + gameId: testGameOnlyGroup.id, + ); + + expect(gameHasPlayers, true); + }); + + test('Adding a player to a game works correctly', () async { + await database.gameDao.addGame(game: testGameOnlyGroup); + await database.playerDao.addPlayer(player: testPlayer5); + await database.playerGameDao.addPlayerToGame( + gameId: testGameOnlyGroup.id, + playerId: testPlayer5.id, + ); + + var playerAdded = await database.playerGameDao.isPlayerInGame( + gameId: testGameOnlyGroup.id, + playerId: testPlayer5.id, + ); + + expect(playerAdded, true); + + playerAdded = await database.playerGameDao.isPlayerInGame( + gameId: testGameOnlyGroup.id, + playerId: '', + ); + + expect(playerAdded, false); + }); + + test('Removing player from game works correctly', () async { + await database.gameDao.addGame(game: testGameOnlyPlayers); + + final playerToRemove = testGameOnlyPlayers.players![0]; + + final removed = await database.playerGameDao.removePlayerFromGame( + playerId: playerToRemove.id, + gameId: testGameOnlyPlayers.id, + ); + expect(removed, true); + + final result = await database.gameDao.getGameById( + gameId: testGameOnlyPlayers.id, + ); + expect(result.players!.length, testGameOnlyPlayers.players!.length - 1); + + final playerExists = result.players!.any( + (p) => p.id == playerToRemove.id, + ); + expect(playerExists, false); + }); + + test('Retrieving players of a game works correctly', () async { + await database.gameDao.addGame(game: testGameOnlyPlayers); + final players = await database.playerGameDao.getPlayersOfGame( + gameId: testGameOnlyPlayers.id, + ); + + if (players == null) { + fail('Players should not be null'); + } + + for (int i = 0; i < players.length; i++) { + expect(players[i].id, testGameOnlyPlayers.players![i].id); + expect(players[i].name, testGameOnlyPlayers.players![i].name); + expect(players[i].createdAt, testGameOnlyPlayers.players![i].createdAt); + } + }); + }); +} diff --git a/test/db_tests/player_group_test.dart b/test/db_tests/player_group_test.dart new file mode 100644 index 0000000..2783430 --- /dev/null +++ b/test/db_tests/player_group_test.dart @@ -0,0 +1,103 @@ +import 'package:clock/clock.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/data/dto/player.dart'; + +void main() { + late AppDatabase database; + late Player testPlayer1; + late Player testPlayer2; + late Player testPlayer3; + late Player testPlayer4; + late Group testgroup; + final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); + final fakeClock = Clock(() => fixedDate); + + setUp(() { + database = AppDatabase( + DatabaseConnection( + NativeDatabase.memory(), + // Recommended for widget tests to avoid test errors. + closeStreamsSynchronously: true, + ), + ); + + withClock(fakeClock, () { + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testgroup = Group( + name: 'Test Group', + members: [testPlayer1, testPlayer2, testPlayer3], + ); + }); + }); + tearDown(() async { + await database.close(); + }); + + group('Player-Group Tests', () { + /// No need to test if group has players since the members attribute is + /// not nullable + + test('Adding a player to a group works correctly', () async { + await database.groupDao.addGroup(group: testgroup); + await database.playerDao.addPlayer(player: testPlayer4); + await database.playerGroupDao.addPlayerToGroup( + groupId: testgroup.id, + player: testPlayer4, + ); + + var playerAdded = await database.playerGroupDao.isPlayerInGroup( + groupId: testgroup.id, + playerId: testPlayer4.id, + ); + + expect(playerAdded, true); + + playerAdded = await database.playerGroupDao.isPlayerInGroup( + groupId: testgroup.id, + playerId: '', + ); + + expect(playerAdded, false); + }); + + test('Removing player from group works correctly', () async { + await database.groupDao.addGroup(group: testgroup); + + final playerToRemove = testgroup.members[0]; + + final removed = await database.playerGroupDao.removePlayerFromGroup( + playerId: playerToRemove.id, + groupId: testgroup.id, + ); + expect(removed, true); + + final result = await database.groupDao.getGroupById( + groupId: testgroup.id, + ); + expect(result.members.length, testgroup.members.length - 1); + + final playerExists = result.members.any((p) => p.id == playerToRemove.id); + expect(playerExists, false); + }); + + test('Retrieving players of a group works correctly', () async { + await database.groupDao.addGroup(group: testgroup); + final players = await database.playerGroupDao.getPlayersOfGroup( + groupId: testgroup.id, + ); + + for (int i = 0; i < players.length; i++) { + expect(players[i].id, testgroup.members[i].id); + expect(players[i].name, testgroup.members[i].name); + expect(players[i].createdAt, testgroup.members[i].createdAt); + } + }); + }); +} diff --git a/test/db_tests/player_test.dart b/test/db_tests/player_test.dart index fa65f67..9a1ba16 100644 --- a/test/db_tests/player_test.dart +++ b/test/db_tests/player_test.dart @@ -7,8 +7,10 @@ import 'package:game_tracker/data/dto/player.dart'; void main() { late AppDatabase database; - late Player testPlayer; + late Player testPlayer1; late Player testPlayer2; + late Player testPlayer3; + late Player testPlayer4; final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); final fakeClock = Clock(() => fixedDate); @@ -22,27 +24,29 @@ void main() { ); withClock(fakeClock, () { - testPlayer = Player(name: 'Test Player'); - testPlayer2 = Player(name: 'Second Group'); + testPlayer1 = Player(name: 'Test Player'); + testPlayer2 = Player(name: 'Second Player'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); }); }); tearDown(() async { await database.close(); }); - group('player tests', () { - test('all players get fetched correctly', () async { - await database.playerDao.addPlayer(player: testPlayer); + group('Player Tests', () { + test('Adding and fetching single player works correctly', () async { + await database.playerDao.addPlayer(player: testPlayer1); await database.playerDao.addPlayer(player: testPlayer2); final allPlayers = await database.playerDao.getAllPlayers(); expect(allPlayers.length, 2); final fetchedPlayer1 = allPlayers.firstWhere( - (g) => g.id == testPlayer.id, + (g) => g.id == testPlayer1.id, ); - expect(fetchedPlayer1.name, testPlayer.name); - expect(fetchedPlayer1.createdAt, testPlayer.createdAt); + expect(fetchedPlayer1.name, testPlayer1.name); + expect(fetchedPlayer1.createdAt, testPlayer1.createdAt); final fetchedPlayer2 = allPlayers.firstWhere( (g) => g.id == testPlayer2.id, @@ -51,62 +55,105 @@ void main() { expect(fetchedPlayer2.createdAt, testPlayer2.createdAt); }); - test('players get inserted correcly ', () async { - await database.playerDao.addPlayer(player: testPlayer); - final result = await database.playerDao.getPlayerById( - playerId: testPlayer.id, + test('Adding and fetching multiple players works correctly', () async { + await database.playerDao.addPlayers( + players: [testPlayer1, testPlayer2, testPlayer3, testPlayer4], ); - expect(result.id, testPlayer.id); - expect(result.name, testPlayer.name); - expect(result.createdAt, testPlayer.createdAt); + final allPlayers = await database.playerDao.getAllPlayers(); + expect(allPlayers.length, 4); + + // Map for connencting fetched players with expected players + final testPlayers = { + testPlayer1.id: testPlayer1, + testPlayer2.id: testPlayer2, + testPlayer3.id: testPlayer3, + testPlayer4.id: testPlayer4, + }; + + for (final player in allPlayers) { + final testPlayer = testPlayers[player.id]!; + + expect(player.id, testPlayer.id); + expect(player.name, testPlayer.name); + expect(player.createdAt, testPlayer.createdAt); + } }); - test('players get deleted correcly ', () async { - await database.playerDao.addPlayer(player: testPlayer); + test('Adding the same player twice does not create duplicates', () async { + await database.playerDao.addPlayer(player: testPlayer1); + await database.playerDao.addPlayer(player: testPlayer1); + + final allPlayers = await database.playerDao.getAllPlayers(); + expect(allPlayers.length, 1); + }); + + test('Player existence check works correctly', () async { + var playerExists = await database.playerDao.playerExists( + playerId: testPlayer1.id, + ); + expect(playerExists, false); + + await database.playerDao.addPlayer(player: testPlayer1); + + playerExists = await database.playerDao.playerExists( + playerId: testPlayer1.id, + ); + expect(playerExists, true); + }); + + test('Deleting a player works correctly', () async { + await database.playerDao.addPlayer(player: testPlayer1); final playerDeleted = await database.playerDao.deletePlayer( - playerId: testPlayer.id, + playerId: testPlayer1.id, ); expect(playerDeleted, true); final playerExists = await database.playerDao.playerExists( - playerId: testPlayer.id, + playerId: testPlayer1.id, ); expect(playerExists, false); }); - test('player name gets updated correcly ', () async { - await database.playerDao.addPlayer(player: testPlayer); + test('Updating a player name works correcly', () async { + await database.playerDao.addPlayer(player: testPlayer1); const newPlayerName = 'new player name'; await database.playerDao.updatePlayername( - playerId: testPlayer.id, + playerId: testPlayer1.id, newName: newPlayerName, ); final result = await database.playerDao.getPlayerById( - playerId: testPlayer.id, + playerId: testPlayer1.id, ); expect(result.name, newPlayerName); }); - test('get player count works correctly', () async { - final initialCount = await database.playerDao.getPlayerCount(); - expect(initialCount, 0); + test('Getting the player count works correctly', () async { + var playerCount = await database.playerDao.getPlayerCount(); + expect(playerCount, 0); - await database.playerDao.addPlayer(player: testPlayer); + await database.playerDao.addPlayer(player: testPlayer1); - final playerAdded = await database.playerDao.getPlayerCount(); - expect(playerAdded, 1); + playerCount = await database.playerDao.getPlayerCount(); + expect(playerCount, 1); - final playerRemoved = await database.playerDao.deletePlayer( - playerId: testPlayer.id, - ); - expect(playerRemoved, true); + await database.playerDao.addPlayer(player: testPlayer2); - final finalCount = await database.playerDao.getPlayerCount(); - expect(finalCount, 0); + playerCount = await database.playerDao.getPlayerCount(); + expect(playerCount, 2); + + await database.playerDao.deletePlayer(playerId: testPlayer1.id); + + playerCount = await database.playerDao.getPlayerCount(); + expect(playerCount, 1); + + await database.playerDao.deletePlayer(playerId: testPlayer2.id); + + playerCount = await database.playerDao.getPlayerCount(); + expect(playerCount, 0); }); }); }