Merge remote-tracking branch 'origin/development' into feature/2-gamehistoryview-anpassen

This commit is contained in:
Yannick
2025-11-23 20:07:10 +01:00
86 changed files with 2560 additions and 809 deletions

View File

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

View File

@@ -15,11 +15,24 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
Future<List<Game>> 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<AppDatabase> with _$GameDaoMixin {
List<Player>? 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<AppDatabase> 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<AppDatabase> with _$GameDaoMixin {
/// Also adds associated players and group if they exist.
Future<void> 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<void> addGames({required List<Game> 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 = <String, Player>{};
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<AppDatabase> 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<bool> deleteAllGames() async {
final query = delete(gameTable);
final rowsAffected = await query.go();
return rowsAffected > 0;
}
}

View File

@@ -4,6 +4,5 @@ part of 'game_dao.dart';
// ignore_for_file: type=lint
mixin _$GameDaoMixin on DatabaseAccessor<AppDatabase> {
$PlayerTableTable get playerTable => attachedDatabase.playerTable;
$GameTableTable get gameTable => attachedDatabase.gameTable;
}

View File

@@ -16,7 +16,7 @@ class GroupDao extends DatabaseAccessor<AppDatabase> 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<AppDatabase> with _$GroupDaoMixin {
final query = select(groupTable)..where((g) => g.id.equals(groupId));
final result = await query.getSingle();
List<Player> members = await db.playerGroupDao.getPlayersOfGroupById(
List<Player> members = await db.playerGroupDao.getPlayersOfGroup(
groupId: groupId,
);
@@ -57,6 +57,10 @@ class GroupDao extends DatabaseAccessor<AppDatabase> 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<AppDatabase> 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<void> addGroups({required List<Group> groups}) async {
if (groups.isEmpty) return;
await db.transaction(() async {
// Deduplicate groups by id - keep first occurrence
final Map<String, Group> 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 = <String, Player>{};
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<String> seenPairs = {};
final List<PlayerGroupTableCompanion> 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<bool> deleteGroup({required String groupId}) async {
@@ -117,4 +198,12 @@ class GroupDao extends DatabaseAccessor<AppDatabase> 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<bool> deleteAllGroups() async {
final query = delete(groupTable);
final rowsAffected = await query.go();
return rowsAffected > 0;
}
}

View File

@@ -10,9 +10,33 @@ class GroupGameDao extends DatabaseAccessor<AppDatabase>
with _$GroupGameDaoMixin {
GroupGameDao(super.db);
/// Associates a group with a game by inserting a record into the
/// [GroupGameTable].
Future<void> 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<Group?> 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<bool> hasGameGroup({required String gameId}) async {
Future<bool> gameHasGroup({required String gameId}) async {
final count =
await (selectOnly(groupGameTable)
..where(groupGameTable.gameId.equals(gameId))
@@ -22,21 +46,34 @@ class GroupGameDao extends DatabaseAccessor<AppDatabase>
return (count ?? 0) > 0;
}
/// Retrieves the [Group] associated with the given [gameId].
Future<Group> 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<bool> 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<void> 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<bool> 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;
}
}

View File

@@ -5,7 +5,6 @@ part of 'group_game_dao.dart';
// ignore_for_file: type=lint
mixin _$GroupGameDaoMixin on DatabaseAccessor<AppDatabase> {
$GroupTableTable get groupTable => attachedDatabase.groupTable;
$PlayerTableTable get playerTable => attachedDatabase.playerTable;
$GameTableTable get gameTable => attachedDatabase.gameTable;
$GroupGameTableTable get groupGameTable => attachedDatabase.groupGameTable;
}

View File

@@ -42,12 +42,36 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> 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<bool> addPlayers({required List<Player> 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<bool> deletePlayer({required String playerId}) async {
@@ -82,4 +106,12 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
.getSingle();
return count ?? 0;
}
/// Deletes all players from the database.
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> deleteAllPlayers() async {
final query = delete(playerTable);
final rowsAffected = await query.go();
return rowsAffected > 0;
}
}

View File

@@ -10,6 +10,34 @@ class PlayerGameDao extends DatabaseAccessor<AppDatabase>
with _$PlayerGameDaoMixin {
PlayerGameDao(super.db);
/// Associates a player with a game by inserting a record into the
/// [PlayerGameTable].
Future<void> 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<List<Player>?> 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<bool> gameHasPlayers({required String gameId}) async {
@@ -22,30 +50,33 @@ class PlayerGameDao extends DatabaseAccessor<AppDatabase>
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<List<Player>> getPlayersByGameId({required String gameId}) async {
final result = await (select(
playerGameTable,
)..where((p) => p.gameId.equals(gameId))).get();
if (result.isEmpty) return <Player>[];
final futures = result.map(
(row) => db.playerDao.getPlayerById(playerId: row.playerId),
);
final players = await Future.wait(futures);
return players.whereType<Player>().toList();
}
/// Associates a player with a game by inserting a record into the
/// [PlayerGameTable].
Future<void> addPlayerToGame({
/// Checks if a specific player is associated with a specific game.
/// Returns `true` if the player is in the game, otherwise `false`.
Future<bool> 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<bool> 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;
}
}

View File

@@ -10,8 +10,34 @@ class PlayerGroupDao extends DatabaseAccessor<AppDatabase>
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<bool> 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<List<Player>> getPlayersOfGroupById({required String groupId}) async {
Future<List<Player>> 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<AppDatabase>
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<bool> 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<bool> isPlayerInGroup({

View File

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

View File

@@ -552,12 +552,9 @@ class $GameTableTable extends GameTable
late final GeneratedColumn<String> winnerId = GeneratedColumn<String>(
'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<GameTableData> {
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<GameTableData> {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['name'] = Variable<String>(name);
map['winner_id'] = Variable<String>(winnerId);
if (!nullToAbsent || winnerId != null) {
map['winner_id'] = Variable<String>(winnerId);
}
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
@@ -672,7 +669,9 @@ class GameTableData extends DataClass implements Insertable<GameTableData> {
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<GameTableData> {
return GameTableData(
id: serializer.fromJson<String>(json['id']),
name: serializer.fromJson<String>(json['name']),
winnerId: serializer.fromJson<String>(json['winnerId']),
winnerId: serializer.fromJson<String?>(json['winnerId']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@@ -695,7 +694,7 @@ class GameTableData extends DataClass implements Insertable<GameTableData> {
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'name': serializer.toJson<String>(name),
'winnerId': serializer.toJson<String>(winnerId),
'winnerId': serializer.toJson<String?>(winnerId),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
@@ -703,12 +702,12 @@ class GameTableData extends DataClass implements Insertable<GameTableData> {
GameTableData copyWith({
String? id,
String? name,
String? winnerId,
Value<String?> 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<GameTableData> {
class GameTableCompanion extends UpdateCompanion<GameTableData> {
final Value<String> id;
final Value<String> name;
final Value<String> winnerId;
final Value<String?> winnerId;
final Value<DateTime> createdAt;
final Value<int> rowid;
const GameTableCompanion({
@@ -759,12 +758,11 @@ class GameTableCompanion extends UpdateCompanion<GameTableData> {
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<GameTableData> custom({
Expression<String>? id,
@@ -785,7 +783,7 @@ class GameTableCompanion extends UpdateCompanion<GameTableData> {
GameTableCompanion copyWith({
Value<String>? id,
Value<String>? name,
Value<String>? winnerId,
Value<String?>? winnerId,
Value<DateTime>? createdAt,
Value<int>? 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<GameTableData>>
_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<String>('id')!));
final cache = $_typedResult.readTableOrNull(_gameTableRefsTable($_db));
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache),
);
}
static MultiTypedResultKey<$PlayerGroupTableTable, List<PlayerGroupTableData>>
_playerGroupTableRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.playerGroupTable,
@@ -1698,31 +1671,6 @@ class $$PlayerTableTableFilterComposer
builder: (column) => ColumnFilters(column),
);
Expression<bool> gameTableRefs(
Expression<bool> 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<bool> playerGroupTableRefs(
Expression<bool> Function($$PlayerGroupTableTableFilterComposer f) f,
) {
@@ -1817,31 +1765,6 @@ class $$PlayerTableTableAnnotationComposer
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
Expression<T> gameTableRefs<T extends Object>(
Expression<T> 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<T> playerGroupTableRefs<T extends Object>(
Expression<T> 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<String?> winnerId,
required DateTime createdAt,
Value<int> rowid,
});
@@ -2444,7 +2339,7 @@ typedef $$GameTableTableUpdateCompanionBuilder =
GameTableCompanion Function({
Value<String> id,
Value<String> name,
Value<String> winnerId,
Value<String?> winnerId,
Value<DateTime> createdAt,
Value<int> 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<String>('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<PlayerGameTableData>>
_playerGameTableRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.playerGameTable,
@@ -2530,34 +2406,16 @@ class $$GameTableTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get winnerId => $composableBuilder(
column: $table.winnerId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> 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<bool> playerGameTableRefs(
Expression<bool> Function($$PlayerGameTableTableFilterComposer f) f,
) {
@@ -2628,33 +2486,15 @@ class $$GameTableTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get winnerId => $composableBuilder(
column: $table.winnerId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> 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<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
GeneratedColumn<String> get winnerId =>
$composableBuilder(column: $table.winnerId, builder: (column) => column);
GeneratedColumn<DateTime> 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<T> playerGameTableRefs<T extends Object>(
Expression<T> 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<String> id = const Value.absent(),
Value<String> name = const Value.absent(),
Value<String> winnerId = const Value.absent(),
Value<String?> winnerId = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => GameTableCompanion(
@@ -2797,7 +2616,7 @@ class $$GameTableTableTableManager
({
required String id,
required String name,
required String winnerId,
Value<String?> winnerId = const Value.absent(),
required DateTime createdAt,
Value<int> 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,
})

View File

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

View File

@@ -5,11 +5,11 @@ import 'package:uuid/uuid.dart';
class Game {
final String id;
final DateTime createdAt;
final String name;
final List<Player>? 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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'createdAt': createdAt.toIso8601String(),
'name': name,
'players': players?.map((player) => player.toJson()).toList(),
'group': group?.toJson(),
'winner': winner?.toJson(),
};
}

View File

@@ -4,9 +4,9 @@ import 'package:uuid/uuid.dart';
class Group {
final String id;
final DateTime createdAt;
final String name;
final List<Player> 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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'createdAt': createdAt.toIso8601String(),
'name': name,
'members': members.map((member) => member.toJson()).toList(),
};
}

View File

@@ -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<String, dynamic> json)
: id = json['id'],
createdAt = DateTime.parse(json['createdAt']),
name = json['name'];
/// Converts the Player instance to a JSON object.
Map<String, dynamic> toJson() => {
'id': id,
'createdAt': createdAt.toIso8601String(),
'name': name,
};
}

View File

@@ -49,13 +49,16 @@ class _CreateGroupViewState extends State<CreateGroupView> {
@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<CreateGroupView> {
@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<CreateGroupView> {
.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<CreateGroupView> {
),
);
}
}
/// 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),
),
),
),
),
);
);
}
}
}

View File

@@ -17,12 +17,7 @@ class CustomNavigationBar extends StatefulWidget {
class _CustomNavigationBarState extends State<CustomNavigationBar>
with SingleTickerProviderStateMixin {
int currentIndex = 0;
final List<Widget> tabs = [
const HomeView(),
const GameHistoryView(),
const GroupsView(),
const StatisticsView(),
];
int tabKeyCount = 0;
@override
void initState() {
@@ -31,6 +26,22 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
@override
Widget build(BuildContext context) {
// Pretty ugly but works
final List<Widget> 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<CustomNavigationBar>
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<CustomNavigationBar>
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(

View File

@@ -34,7 +34,10 @@ class _GroupsViewState extends State<GroupsView> {
void initState() {
super.initState();
db = Provider.of<AppDatabase>(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<GroupsView> {
}
final bool isLoading =
snapshot.connectionState == ConnectionState.waiting;
final List<Group> groups = isLoading
? skeletonData
: (snapshot.data ?? []);
final List<Group> 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<GroupsView> {
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<GroupsView> {
),
Positioned(
bottom: 80,
bottom: MediaQuery.paddingOf(context).bottom,
child: CustomWidthButton(
text: 'Create Group',
sizeRelativeToWidth: 0.90,

View File

@@ -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<HomeView> {
late Future<int> _gameCountFuture;
late Future<int> _groupCountFuture;
late Future<List<Game>> _recentGamesFuture;
bool isLoading = true;
late final List<Game> 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<AppDatabase>(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<HomeView> {
),
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<Widget> previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
),
child: SingleChildScrollView(
child: Column(
@@ -103,32 +134,91 @@ class _HomeViewState extends State<HomeView> {
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<List<Game>> 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<Game> 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<HomeView> {
},
);
}
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}';
}
}

View File

@@ -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<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
@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<bool>(
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,
),
);
}
}

View File

@@ -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<StatisticsView> createState() => _StatisticsViewState();
}
class _StatisticsViewState extends State<StatisticsView> {
late Future<List<Game>> _gamesFuture;
late Future<List<Player>> _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<AppDatabase>(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<Game>;
final players = results[1] as List<Player>;
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<Widget> 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<Game> games,
List<Player> 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<Game> games,
List<Player> 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<String, int> winsMap = {for (var e in wins) e.$1: e.$2};
final Map<String, int> 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;
}
}

View File

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

View File

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

View File

@@ -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<void> deleteAllData(BuildContext context) async {
final db = Provider.of<AppDatabase>(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<String> getAppDataAsJson(BuildContext context) async {
final db = Provider.of<AppDatabase>(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<String, dynamic> 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<ExportResult> 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<ImportResult> importData(BuildContext context) async {
final db = Provider.of<AppDatabase>(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<String, dynamic> jsonData =
json.decode(jsonString) as Map<String, dynamic>;
final List<dynamic>? gamesJson = jsonData['games'] as List<dynamic>?;
final List<dynamic>? groupsJson = jsonData['groups'] as List<dynamic>?;
final List<dynamic>? playersJson =
jsonData['players'] as List<dynamic>?;
final List<Game> importedGames =
gamesJson
?.map((g) => Game.fromJson(g as Map<String, dynamic>))
.toList() ??
[];
final List<Group> importedGroups =
groupsJson
?.map((g) => Group.fromJson(g as Map<String, dynamic>))
.toList() ??
[];
final List<Player> importedPlayers =
playersJson
?.map((p) => Player.fromJson(p as Map<String, dynamic>))
.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<String?> _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<bool> _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;
}
}
}