diff --git a/analysis_options.yaml b/analysis_options.yaml index c0978e6..dc1e1c5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1,9 @@ include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart + linter: rules: avoid_print: false @@ -11,8 +15,4 @@ linter: prefer_const_literals_to_create_immutables: true unnecessary_const: true lines_longer_than_80_chars: false - constant_identifier_names: false - -analyzer: - exclude: - - lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart + constant_identifier_names: false \ No newline at end of file diff --git a/assets/schema.json b/assets/schema.json index f5e363b..6bcbe45 100644 --- a/assets/schema.json +++ b/assets/schema.json @@ -147,13 +147,19 @@ "type": "string" }, "endedAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "gameId": { "type": "string" }, "groupId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "playerIds": { "type": "array", @@ -163,22 +169,28 @@ }, "scores": { "type": "object", - "items": { - "type": "array", - "items": { - "type": "string", - "properties": { - "roundNumber": { - "type": "number" + "additionalProperties": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "roundNumber": { + "type": "number" + }, + "score": { + "type": "number" + }, + "change": { + "type": "number" + } }, - "score": { - "type": "number" - }, - "change": { - "type": "number" - } + "required": ["roundNumber", "score", "change"], + "additionalProperties": false } - } + ] } }, "notes": { diff --git a/lib/core/common.dart b/lib/core/common.dart index 20b0225..8027180 100644 --- a/lib/core/common.dart +++ b/lib/core/common.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/data/models/match.dart'; +import 'package:tallee/data/models/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; /// Translates a [Ruleset] enum value to its corresponding localized string. @@ -43,3 +44,18 @@ String getExtraPlayerCount(Match match) { } return ' + ${count.toString()}'; } + +String getNameCountText(Player player) { + if (player.nameCount >= 1) { + return ' #${player.nameCount}'; + } + return ''; +} + +String getPointLabel(AppLocalizations loc, int points) { + if (points == 1) { + return '$points ${loc.point}'; + } else { + return '$points ${loc.points}'; + } +} diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index d1b158e..3274db9 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -85,21 +85,21 @@ class CustomTheme { ); static const SearchBarThemeData searchBarTheme = SearchBarThemeData( - textStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.textColor)), - hintStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.hintColor)), + textStyle: WidgetStatePropertyAll(TextStyle(color: textColor)), + hintStyle: WidgetStatePropertyAll(TextStyle(color: hintColor)), ); static final RadioThemeData radioTheme = RadioThemeData( fillColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return CustomTheme.primaryColor; + return primaryColor; } - return CustomTheme.textColor; + return textColor; }), ); static const InputDecorationTheme inputDecorationTheme = InputDecorationTheme( - labelStyle: TextStyle(color: CustomTheme.textColor), - hintStyle: TextStyle(color: CustomTheme.hintColor), + labelStyle: TextStyle(color: textColor), + hintStyle: TextStyle(color: hintColor), ); } diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 81925c7..93df7d7 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -34,7 +34,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { matchId: row.id, ); - final winner = await db.scoreEntryDao.getWinner(matchId: row.id); return Match( id: row.id, name: row.name, @@ -45,7 +44,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { createdAt: row.createdAt, endedAt: row.endedAt, scores: scores, - winner: winner, ); }), ); @@ -68,8 +66,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { final scores = await db.scoreEntryDao.getAllMatchScores(matchId: matchId); - final winner = await db.scoreEntryDao.getWinner(matchId: matchId); - return Match( id: result.id, name: result.name, @@ -80,7 +76,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { createdAt: result.createdAt, endedAt: result.endedAt, scores: scores, - winner: winner, ); } @@ -110,19 +105,14 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { } for (final pid in match.scores.keys) { - final playerScores = match.scores[pid]!; - await db.scoreEntryDao.addScoresAsList( - entrys: playerScores, - playerId: pid, - matchId: match.id, - ); - } - - if (match.winner != null) { - await db.scoreEntryDao.setWinner( - matchId: match.id, - playerId: match.winner!.id, - ); + final playerScores = match.scores[pid]; + if (playerScores != null) { + await db.scoreEntryDao.addScore( + entry: playerScores, + playerId: pid, + matchId: match.id, + ); + } } }); } @@ -140,6 +130,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { uniqueGames[match.game.id] = match.game; } + // Add games if (uniqueGames.isNotEmpty) { await db.batch( (b) => b.insertAll( @@ -162,7 +153,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ); } - // Add all groups of the matches in batch + // Add groups await db.batch( (b) => b.insertAll( db.groupTable, @@ -181,7 +172,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ), ); - // Add all matches in batch + // Add matches await db.batch( (b) => b.insertAll( matchTable, @@ -202,7 +193,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ), ); - // Add all players of the matches in batch (unique) + // Add players final uniquePlayers = {}; for (final match in matches) { for (final p in match.players) { @@ -235,7 +226,27 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ); } - // Add all player-match associations in batch + await db.batch((b) { + for (final match in matches) { + for (final entry in match.scores.entries) { + if (entry.value != null) { + b.insert( + db.scoreEntryTable, + ScoreEntryTableCompanion.insert( + matchId: match.id, + playerId: entry.key, + score: entry.value!.score, + roundNumber: entry.value!.roundNumber, + change: entry.value!.change, + ), + mode: InsertMode.insertOrReplace, + ); + } + } + } + }); + + // Add player-match associations await db.batch((b) { for (final match in matches) { for (final p in match.players) { @@ -251,7 +262,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { } }); - // Add all player-group associations in batch + // Add player-group associations await db.batch((b) { for (final match in matches) { if (match.group != null) { @@ -300,7 +311,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { final group = await db.groupDao.getGroupById(groupId: groupId); final players = await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? []; - final winner = await db.scoreEntryDao.getWinner(matchId: row.id); return Match( id: row.id, name: row.name, @@ -310,7 +320,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { notes: row.notes ?? '', createdAt: row.createdAt, endedAt: row.endedAt, - winner: winner, ); }), ); diff --git a/lib/data/dao/player_dao.dart b/lib/data/dao/player_dao.dart index c58cb9a..5d46343 100644 --- a/lib/data/dao/player_dao.dart +++ b/lib/data/dao/player_dao.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:flutter/cupertino.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/db/tables/player_table.dart'; import 'package:tallee/data/models/player.dart'; @@ -20,6 +21,7 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { name: row.name, description: row.description, createdAt: row.createdAt, + nameCount: row.nameCount, ), ) .toList(); @@ -34,6 +36,7 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { name: result.name, description: result.description, createdAt: result.createdAt, + nameCount: result.nameCount, ); } @@ -42,12 +45,15 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { /// the new one. Future addPlayer({required Player player}) async { if (!await playerExists(playerId: player.id)) { + final int nameCount = await calculateNameCount(name: player.name); + await into(playerTable).insert( PlayerTableCompanion.insert( id: player.id, name: player.name, description: player.description, createdAt: player.createdAt, + nameCount: Value(nameCount), ), mode: InsertMode.insertOrReplace, ); @@ -62,20 +68,67 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { Future addPlayersAsList({required List players}) async { if (players.isEmpty) return false; + // Filter out players that already exist + final newPlayers = []; + for (final player in players) { + if (!await playerExists(playerId: player.id)) { + newPlayers.add(player); + } + } + + if (newPlayers.isEmpty) return false; + + // Group players by name + final nameGroups = >{}; + for (final player in newPlayers) { + nameGroups.putIfAbsent(player.name, () => []).add(player); + } + + final playersToInsert = []; + + // Process each group of players with the same name + for (final entry in nameGroups.entries) { + final name = entry.key; + final playersWithName = entry.value; + + // Get the current nameCount + var nameCount = await calculateNameCount(name: name); + + // One player with the same name + if (playersWithName.length == 1) { + final player = playersWithName[0]; + playersToInsert.add( + PlayerTableCompanion.insert( + id: player.id, + name: player.name, + description: player.description, + createdAt: player.createdAt, + nameCount: Value(nameCount), + ), + ); + } else { + if (nameCount == 0) nameCount++; + // Multiple players with the same name + for (var i = 0; i < playersWithName.length; i++) { + final player = playersWithName[i]; + playersToInsert.add( + PlayerTableCompanion.insert( + id: player.id, + name: player.name, + description: player.description, + createdAt: player.createdAt, + nameCount: Value(nameCount + i), + ), + ); + } + } + } + await db.batch( (b) => b.insertAll( playerTable, - players - .map( - (player) => PlayerTableCompanion.insert( - id: player.id, - name: player.name, - description: player.description, - createdAt: player.createdAt, - ), - ) - .toList(), - mode: InsertMode.insertOrIgnore, + playersToInsert, + mode: InsertMode.insertOrReplace, ), ); @@ -90,7 +143,7 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { return rowsAffected > 0; } - /// Checks if a player with the given [id] exists in the database. + /// Checks if a player with the given [playerId] exists in the database. /// Returns `true` if the player exists, `false` otherwise. Future playerExists({required String playerId}) async { final query = select(playerTable)..where((p) => p.id.equals(playerId)); @@ -103,9 +156,38 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { required String playerId, required String newName, }) async { + // Get previous name and name count for the player before updating + final previousPlayerName = + await (select(playerTable)..where((p) => p.id.equals(playerId))) + .map((row) => row.name) + .getSingleOrNull() ?? + ''; + final previousNameCount = await getNameCount(name: previousPlayerName); + await (update(playerTable)..where((p) => p.id.equals(playerId))).write( PlayerTableCompanion(name: Value(newName)), ); + + // Update name count for the new name + final count = await calculateNameCount(name: newName); + if (count > 0) { + await (update(playerTable)..where((p) => p.name.equals(newName))).write( + PlayerTableCompanion(nameCount: Value(count)), + ); + } + + if (previousNameCount > 0) { + // Get the player with that name and the hightest nameCount, and update their nameCount to previousNameCount + final player = await getPlayerWithHighestNameCount( + name: previousPlayerName, + ); + if (player != null) { + await updateNameCount( + playerId: player.id, + nameCount: previousNameCount, + ); + } + } } /// Retrieves the total count of players in the database. @@ -117,6 +199,76 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { return count ?? 0; } + /// Retrieves the count of players with the given [name]. + Future getNameCount({required String name}) async { + final query = select(playerTable)..where((p) => p.name.equals(name)); + final result = await query.get(); + return result.length; + } + + /// Updates the nameCount for the player with the given [playerId] to [nameCount]. + /// Returns `true` if more than 0 rows were affected, otherwise `false`. + Future updateNameCount({ + required String playerId, + required int nameCount, + }) async { + final query = update(playerTable)..where((p) => p.id.equals(playerId)); + final rowsAffected = await query.write( + PlayerTableCompanion(nameCount: Value(nameCount)), + ); + return rowsAffected > 0; + } + + @visibleForTesting + Future getPlayerWithHighestNameCount({required String name}) async { + final query = select(playerTable) + ..where((p) => p.name.equals(name)) + ..orderBy([(p) => OrderingTerm.desc(p.nameCount)]) + ..limit(1); + final result = await query.getSingleOrNull(); + if (result != null) { + return Player( + id: result.id, + name: result.name, + description: result.description, + createdAt: result.createdAt, + nameCount: result.nameCount, + ); + } + return null; + } + + @visibleForTesting + Future calculateNameCount({required String name}) async { + final count = await getNameCount(name: name); + final int nameCount; + + if (count == 1) { + // If one other player exists with the same name, initialize the nameCount + await initializeNameCount(name: name); + // And for the new player, set nameCount to 2 + nameCount = 2; + } else if (count > 1) { + // If more than one player exists with the same name, just increment + // the nameCount for the new player + nameCount = count + 1; + } else { + // If no other players exist with the same name, set nameCount to 0 + nameCount = 0; + } + + return nameCount; + } + + @visibleForTesting + Future initializeNameCount({required String name}) async { + final rowsAffected = + await (update(playerTable)..where((p) => p.name.equals(name))).write( + const PlayerTableCompanion(nameCount: Value(1)), + ); + return rowsAffected > 0; + } + /// Deletes all players from the database. /// Returns `true` if more than 0 rows were affected, otherwise `false`. Future deleteAllPlayers() async { diff --git a/lib/data/dao/player_match_dao.dart b/lib/data/dao/player_match_dao.dart index 36a7dbe..b467a1b 100644 --- a/lib/data/dao/player_match_dao.dart +++ b/lib/data/dao/player_match_dao.dart @@ -24,7 +24,7 @@ class PlayerMatchDao extends DatabaseAccessor matchId: matchId, teamId: Value(teamId), ), - mode: InsertMode.insertOrIgnore, + mode: InsertMode.insertOrReplace, ); } diff --git a/lib/data/dao/score_entry_dao.dart b/lib/data/dao/score_entry_dao.dart index cdd42f9..566b9d1 100644 --- a/lib/data/dao/score_entry_dao.dart +++ b/lib/data/dao/score_entry_dao.dart @@ -83,21 +83,21 @@ class ScoreEntryDao extends DatabaseAccessor } /// Retrieves all scores for a specific match. - Future>> getAllMatchScores({ + Future> getAllMatchScores({ required String matchId, }) async { final query = select(scoreEntryTable) ..where((s) => s.matchId.equals(matchId)); final result = await query.get(); - final Map> scoresByPlayer = {}; + final Map scoresByPlayer = {}; for (final row in result) { final score = ScoreEntry( roundNumber: row.roundNumber, score: row.score, change: row.change, ); - scoresByPlayer.putIfAbsent(row.playerId, () => []).add(score); + scoresByPlayer[row.playerId] = score; } return scoresByPlayer; @@ -235,22 +235,29 @@ class ScoreEntryDao extends DatabaseAccessor return rowsAffected > 0; } - // Retrieves the winner of a match based on the highest score. + // Retrieves the winner of a match by looking for a score entry where score + /// is 1. Returns `null` if no player found, else the first with the score. Future getWinner({required String matchId}) async { - final query = select(scoreEntryTable) - ..where((s) => s.matchId.equals(matchId)) - ..orderBy([(s) => OrderingTerm.desc(s.score)]) - ..limit(1); - final result = await query.getSingleOrNull(); + final query = + select(scoreEntryTable).join([ + innerJoin( + db.playerTable, + db.playerTable.id.equalsExp(scoreEntryTable.playerId), + ), + ])..where( + scoreEntryTable.matchId.equals(matchId) & + scoreEntryTable.score.equals(1), + ); - if (result == null) return null; + final result = await query.get(); + if (result.isEmpty) return null; - final player = await db.playerDao.getPlayerById(playerId: result.playerId); + final playerData = result.first.readTable(db.playerTable); return Player( - id: player.id, - name: player.name, - createdAt: player.createdAt, - description: player.description, + id: playerData.id, + name: playerData.name, + createdAt: playerData.createdAt, + description: playerData.description, ); } @@ -295,20 +302,29 @@ class ScoreEntryDao extends DatabaseAccessor return rowsAffected > 0; } - /// Retrieves the looser of a match based on the score 0. + /// Retrieves the looser of a match by looking for a score entry where score + /// is 0. Returns `null` if no player found, else the first with the score. Future getLooser({required String matchId}) async { - final query = select(scoreEntryTable) - ..where((s) => s.matchId.equals(matchId) & s.score.equals(0)); - final result = await query.getSingleOrNull(); + final query = + select(scoreEntryTable).join([ + innerJoin( + db.playerTable, + db.playerTable.id.equalsExp(scoreEntryTable.playerId), + ), + ])..where( + scoreEntryTable.matchId.equals(matchId) & + scoreEntryTable.score.equals(0), + ); - if (result == null) return null; + final result = await query.get(); + if (result.isEmpty) return null; - final player = await db.playerDao.getPlayerById(playerId: result.playerId); + final playerData = result.first.readTable(db.playerTable); return Player( - id: player.id, - name: player.name, - createdAt: player.createdAt, - description: player.description, + id: playerData.id, + name: playerData.name, + createdAt: playerData.createdAt, + description: playerData.description, ); } diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index 58175df..2190c3d 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -18,6 +18,17 @@ class $PlayerTableTable extends PlayerTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); static const VerificationMeta _nameMeta = const VerificationMeta('name'); @override late final GeneratedColumn name = GeneratedColumn( @@ -27,6 +38,18 @@ class $PlayerTableTable extends PlayerTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _nameCountMeta = const VerificationMeta( + 'nameCount', + ); + @override + late final GeneratedColumn nameCount = GeneratedColumn( + 'name_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); static const VerificationMeta _descriptionMeta = const VerificationMeta( 'description', ); @@ -38,19 +61,14 @@ class $PlayerTableTable extends PlayerTable type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - @override - List get $columns => [id, name, description, createdAt]; + List get $columns => [ + id, + createdAt, + name, + nameCount, + description, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -68,6 +86,14 @@ class $PlayerTableTable extends PlayerTable } else if (isInserting) { context.missing(_idMeta); } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } if (data.containsKey('name')) { context.handle( _nameMeta, @@ -76,6 +102,12 @@ class $PlayerTableTable extends PlayerTable } else if (isInserting) { context.missing(_nameMeta); } + if (data.containsKey('name_count')) { + context.handle( + _nameCountMeta, + nameCount.isAcceptableOrUnknown(data['name_count']!, _nameCountMeta), + ); + } if (data.containsKey('description')) { context.handle( _descriptionMeta, @@ -87,14 +119,6 @@ class $PlayerTableTable extends PlayerTable } else if (isInserting) { context.missing(_descriptionMeta); } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } else if (isInserting) { - context.missing(_createdAtMeta); - } return context; } @@ -108,18 +132,22 @@ class $PlayerTableTable extends PlayerTable DriftSqlType.string, data['${effectivePrefix}id'], )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, name: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}name'], )!, + nameCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}name_count'], + )!, description: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}description'], )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}created_at'], - )!, ); } @@ -131,31 +159,35 @@ class $PlayerTableTable extends PlayerTable class PlayerTableData extends DataClass implements Insertable { final String id; - final String name; - final String description; final DateTime createdAt; + final String name; + final int nameCount; + final String description; const PlayerTableData({ required this.id, - required this.name, - required this.description, required this.createdAt, + required this.name, + required this.nameCount, + required this.description, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); - map['name'] = Variable(name); - map['description'] = Variable(description); map['created_at'] = Variable(createdAt); + map['name'] = Variable(name); + map['name_count'] = Variable(nameCount); + map['description'] = Variable(description); return map; } PlayerTableCompanion toCompanion(bool nullToAbsent) { return PlayerTableCompanion( id: Value(id), - name: Value(name), - description: Value(description), createdAt: Value(createdAt), + name: Value(name), + nameCount: Value(nameCount), + description: Value(description), ); } @@ -166,9 +198,10 @@ class PlayerTableData extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return PlayerTableData( id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - description: serializer.fromJson(json['description']), createdAt: serializer.fromJson(json['createdAt']), + name: serializer.fromJson(json['name']), + nameCount: serializer.fromJson(json['nameCount']), + description: serializer.fromJson(json['description']), ); } @override @@ -176,31 +209,35 @@ class PlayerTableData extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'description': serializer.toJson(description), 'createdAt': serializer.toJson(createdAt), + 'name': serializer.toJson(name), + 'nameCount': serializer.toJson(nameCount), + 'description': serializer.toJson(description), }; } PlayerTableData copyWith({ String? id, - String? name, - String? description, DateTime? createdAt, + String? name, + int? nameCount, + String? description, }) => PlayerTableData( id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, createdAt: createdAt ?? this.createdAt, + name: name ?? this.name, + nameCount: nameCount ?? this.nameCount, + description: description ?? this.description, ); PlayerTableData copyWithCompanion(PlayerTableCompanion data) { return PlayerTableData( id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, name: data.name.present ? data.name.value : this.name, + nameCount: data.nameCount.present ? data.nameCount.value : this.nameCount, description: data.description.present ? data.description.value : this.description, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, ); } @@ -208,76 +245,85 @@ class PlayerTableData extends DataClass implements Insertable { String toString() { return (StringBuffer('PlayerTableData(') ..write('id: $id, ') + ..write('createdAt: $createdAt, ') ..write('name: $name, ') - ..write('description: $description, ') - ..write('createdAt: $createdAt') + ..write('nameCount: $nameCount, ') + ..write('description: $description') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, name, description, createdAt); + int get hashCode => Object.hash(id, createdAt, name, nameCount, description); @override bool operator ==(Object other) => identical(this, other) || (other is PlayerTableData && other.id == this.id && + other.createdAt == this.createdAt && other.name == this.name && - other.description == this.description && - other.createdAt == this.createdAt); + other.nameCount == this.nameCount && + other.description == this.description); } class PlayerTableCompanion extends UpdateCompanion { final Value id; - final Value name; - final Value description; final Value createdAt; + final Value name; + final Value nameCount; + final Value description; final Value rowid; const PlayerTableCompanion({ this.id = const Value.absent(), - this.name = const Value.absent(), - this.description = const Value.absent(), this.createdAt = const Value.absent(), + this.name = const Value.absent(), + this.nameCount = const Value.absent(), + this.description = const Value.absent(), this.rowid = const Value.absent(), }); PlayerTableCompanion.insert({ required String id, - required String name, - required String description, required DateTime createdAt, + required String name, + this.nameCount = const Value.absent(), + required String description, this.rowid = const Value.absent(), }) : id = Value(id), + createdAt = Value(createdAt), name = Value(name), - description = Value(description), - createdAt = Value(createdAt); + description = Value(description); static Insertable custom({ Expression? id, - Expression? name, - Expression? description, Expression? createdAt, + Expression? name, + Expression? nameCount, + Expression? description, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (name != null) 'name': name, - if (description != null) 'description': description, if (createdAt != null) 'created_at': createdAt, + if (name != null) 'name': name, + if (nameCount != null) 'name_count': nameCount, + if (description != null) 'description': description, if (rowid != null) 'rowid': rowid, }); } PlayerTableCompanion copyWith({ Value? id, - Value? name, - Value? description, Value? createdAt, + Value? name, + Value? nameCount, + Value? description, Value? rowid, }) { return PlayerTableCompanion( id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, createdAt: createdAt ?? this.createdAt, + name: name ?? this.name, + nameCount: nameCount ?? this.nameCount, + description: description ?? this.description, rowid: rowid ?? this.rowid, ); } @@ -288,15 +334,18 @@ class PlayerTableCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } if (name.present) { map['name'] = Variable(name.value); } + if (nameCount.present) { + map['name_count'] = Variable(nameCount.value); + } if (description.present) { map['description'] = Variable(description.value); } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -307,9 +356,10 @@ class PlayerTableCompanion extends UpdateCompanion { String toString() { return (StringBuffer('PlayerTableCompanion(') ..write('id: $id, ') - ..write('name: $name, ') - ..write('description: $description, ') ..write('createdAt: $createdAt, ') + ..write('name: $name, ') + ..write('nameCount: $nameCount, ') + ..write('description: $description, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -2790,17 +2840,19 @@ abstract class _$AppDatabase extends GeneratedDatabase { typedef $$PlayerTableTableCreateCompanionBuilder = PlayerTableCompanion Function({ required String id, - required String name, - required String description, required DateTime createdAt, + required String name, + Value nameCount, + required String description, Value rowid, }); typedef $$PlayerTableTableUpdateCompanionBuilder = PlayerTableCompanion Function({ Value id, - Value name, - Value description, Value createdAt, + Value name, + Value nameCount, + Value description, Value rowid, }); @@ -2892,18 +2944,23 @@ class $$PlayerTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get name => $composableBuilder( column: $table.name, builder: (column) => ColumnFilters(column), ); - ColumnFilters get description => $composableBuilder( - column: $table.description, + ColumnFilters get nameCount => $composableBuilder( + column: $table.nameCount, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column), ); @@ -2997,18 +3054,23 @@ class $$PlayerTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get name => $composableBuilder( column: $table.name, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get description => $composableBuilder( - column: $table.description, + ColumnOrderings get nameCount => $composableBuilder( + column: $table.nameCount, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column), ); } @@ -3025,17 +3087,20 @@ class $$PlayerTableTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get nameCount => + $composableBuilder(column: $table.nameCount, builder: (column) => column); + GeneratedColumn get description => $composableBuilder( column: $table.description, builder: (column) => column, ); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - Expression playerGroupTableRefs( Expression Function($$PlayerGroupTableTableAnnotationComposer a) f, ) { @@ -3145,29 +3210,33 @@ class $$PlayerTableTableTableManager updateCompanionCallback: ({ Value id = const Value.absent(), - Value name = const Value.absent(), - Value description = const Value.absent(), Value createdAt = const Value.absent(), + Value name = const Value.absent(), + Value nameCount = const Value.absent(), + Value description = const Value.absent(), Value rowid = const Value.absent(), }) => PlayerTableCompanion( id: id, - name: name, - description: description, createdAt: createdAt, + name: name, + nameCount: nameCount, + description: description, rowid: rowid, ), createCompanionCallback: ({ required String id, - required String name, - required String description, required DateTime createdAt, + required String name, + Value nameCount = const Value.absent(), + required String description, Value rowid = const Value.absent(), }) => PlayerTableCompanion.insert( id: id, - name: name, - description: description, createdAt: createdAt, + name: name, + nameCount: nameCount, + description: description, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/data/db/tables/player_table.dart b/lib/data/db/tables/player_table.dart index 3b98eba..2dbb4d2 100644 --- a/lib/data/db/tables/player_table.dart +++ b/lib/data/db/tables/player_table.dart @@ -2,9 +2,10 @@ import 'package:drift/drift.dart'; class PlayerTable extends Table { TextColumn get id => text()(); - TextColumn get name => text()(); - TextColumn get description => text()(); DateTimeColumn get createdAt => dateTime()(); + TextColumn get name => text()(); + IntColumn get nameCount => integer().withDefault(const Constant(0))(); + TextColumn get description => text()(); BoolColumn get deleted => boolean().withDefault(const Constant(false))(); @override diff --git a/lib/data/models/game.dart b/lib/data/models/game.dart index 2eeee1e..607db0a 100644 --- a/lib/data/models/game.dart +++ b/lib/data/models/game.dart @@ -1,6 +1,6 @@ import 'package:clock/clock.dart'; -import 'package:uuid/uuid.dart'; import 'package:tallee/core/enums.dart'; +import 'package:uuid/uuid.dart'; class Game { final String id; @@ -33,7 +33,10 @@ class Game { : id = json['id'], createdAt = DateTime.parse(json['createdAt']), name = json['name'], - ruleset = Ruleset.values.firstWhere((e) => e.name == json['ruleset']), + ruleset = Ruleset.values.firstWhere( + (e) => e.name == json['ruleset'], + orElse: () => Ruleset.singleWinner, + ), description = json['description'], color = GameColor.values.firstWhere((e) => e.name == json['color']), icon = json['icon']; @@ -49,4 +52,3 @@ class Game { 'icon': icon, }; } - diff --git a/lib/data/models/match.dart b/lib/data/models/match.dart index 60103de..2ff02d6 100644 --- a/lib/data/models/match.dart +++ b/lib/data/models/match.dart @@ -15,27 +15,25 @@ class Match { final Group? group; final List players; final String notes; - Map> scores; - Player? winner; + Map scores; Match({ - String? id, - DateTime? createdAt, - this.endedAt, required this.name, required this.game, + required this.players, + this.endedAt, this.group, - this.players = const [], this.notes = '', - Map>? scores, - this.winner, + String? id, + DateTime? createdAt, + Map? scores, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(), - scores = scores ?? {for (var player in players) player.id: []}; + scores = scores ?? {for (Player p in players) p.id: null}; @override String toString() { - return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, notes: $notes, scores: $scores, winner: $winner}'; + return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, notes: $notes, scores: $scores, mvp: $mvp}'; } /// Creates a Match instance from a JSON object where related objects are @@ -57,7 +55,16 @@ class Match { ), group = null, players = [], - scores = json['scores'], + scores = json['scores'] != null + ? (json['scores'] as Map).map( + (key, value) => MapEntry( + key, + value != null + ? ScoreEntry.fromJson(value as Map) + : null, + ), + ) + : {}, notes = json['notes'] ?? ''; /// Converts the Match instance to a JSON object. Related objects are @@ -71,10 +78,62 @@ class Match { 'gameId': game.id, 'groupId': group?.id, 'playerIds': players.map((player) => player.id).toList(), - 'scores': scores.map( - (playerId, scoreList) => - MapEntry(playerId, scoreList.map((score) => score.toJson()).toList()), - ), + 'scores': scores.map((key, value) => MapEntry(key, value?.toJson())), 'notes': notes, }; + + List get mvp { + if (players.isEmpty || scores.isEmpty) return []; + + switch (game.ruleset) { + case Ruleset.highestScore: + return _getPlayersWithHighestScore(); + + case Ruleset.lowestScore: + return _getPlayersWithLowestScore(); + + case Ruleset.singleWinner: + return _getPlayersWithHighestScore().take(1).toList(); + + case Ruleset.singleLoser: + return _getPlayersWithLowestScore().take(1).toList(); + + case Ruleset.multipleWinners: + return []; + } + } + + List _getPlayersWithHighestScore() { + if (players.isEmpty || scores.values.every((score) => score == null)) { + return []; + } + + final int highestScore = players + .map((player) => scores[player.id]?.score) + .whereType() + .reduce((max, score) => score > max ? score : max); + + return players.where((player) { + final playerScores = scores[player.id]; + if (playerScores == null) return false; + return playerScores.score == highestScore; + }).toList(); + } + + List _getPlayersWithLowestScore() { + if (players.isEmpty || scores.values.every((score) => score == null)) { + return []; + } + + final int lowestScore = players + .map((player) => scores[player.id]?.score) + .whereType() + .reduce((min, score) => score < min ? score : min); + + return players.where((player) { + final playerScore = scores[player.id]; + if (playerScore == null) return false; + return playerScore.score == lowestScore; + }).toList(); + } } diff --git a/lib/data/models/player.dart b/lib/data/models/player.dart index c405de9..12d17f0 100644 --- a/lib/data/models/player.dart +++ b/lib/data/models/player.dart @@ -5,12 +5,14 @@ class Player { final String id; final DateTime createdAt; final String name; + int nameCount; final String description; Player({ String? id, DateTime? createdAt, required this.name, + this.nameCount = 0, String? description, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(), @@ -18,7 +20,7 @@ class Player { @override String toString() { - return 'Player{id: $id, name: $name, description: $description}'; + return 'Player{id: $id, createdAt: $createdAt, name: $name, nameCount: $nameCount, description: $description}'; } /// Creates a Player instance from a JSON object. @@ -26,6 +28,7 @@ class Player { : id = json['id'], createdAt = DateTime.parse(json['createdAt']), name = json['name'], + nameCount = 0, description = json['description']; /// Converts the Player instance to a JSON object. diff --git a/lib/data/models/score_entry.dart b/lib/data/models/score_entry.dart index 0f8a8c3..f9c5ff0 100644 --- a/lib/data/models/score_entry.dart +++ b/lib/data/models/score_entry.dart @@ -1,13 +1,14 @@ class ScoreEntry { - int roundNumber = 0; + final int roundNumber; final int score; final int change; - ScoreEntry({ - required this.roundNumber, - required this.score, - required this.change, - }); + ScoreEntry({required this.score, this.roundNumber = 0, this.change = 0}); + + @override + String toString() { + return 'ScoreEntry{roundNumber: $roundNumber, score: $score, change: $change}'; + } ScoreEntry.fromJson(Map json) : roundNumber = json['roundNumber'], diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cec565c..46c780a 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -22,10 +22,11 @@ "days_ago": "vor {count} Tagen", "delete": "Löschen", "delete_all_data": "Alle Daten löschen", - "delete_group": "Diese Gruppe löschen", + "delete_group": "Gruppe löschen", "delete_match": "Spiel löschen", "edit_group": "Gruppe bearbeiten", "edit_match": "Gruppe bearbeiten", + "enter_points": "Punkte eingeben", "enter_results": "Ergebnisse eintragen", "error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", "error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen", @@ -74,6 +75,8 @@ "player_name": "Spieler:innenname", "players": "Spieler:innen", "players_count": "{count} Spieler", + "point": "Punkt", + "points": "Punkte", "privacy_policy": "Datenschutzerklärung", "quick_create": "Schnellzugriff", "recent_matches": "Letzte Spiele", @@ -87,12 +90,14 @@ "save_changes": "Änderungen speichern", "search_for_groups": "Nach Gruppen suchen", "search_for_players": "Nach Spieler:innen suchen", - "select_winner": "Gewinner:in wählen:", + "select_winner": "Gewinner:in wählen", + "select_loser": "Verlierer:in wählen", "selected_players": "Ausgewählte Spieler:innen", "settings": "Einstellungen", "single_loser": "Ein:e Verlierer:in", "single_winner": "Ein:e Gewinner:in", "highest_score": "Höchste Punkte", + "loser": "Verlierer:in", "lowest_score": "Niedrigste Punkte", "multiple_winners": "Mehrere Gewinner:innen", "statistics": "Statistiken", @@ -100,6 +105,7 @@ "successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt", "there_is_no_group_matching_your_search": "Es gibt keine Gruppe, die deiner Suche entspricht", "this_cannot_be_undone": "Dies kann nicht rückgängig gemacht werden.", + "tie": "Unentschieden", "today_at": "Heute um", "undo": "Rückgängig", "unknown_exception": "Unbekannter Fehler (siehe Konsole)", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index aea47f7..a85e1b0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -83,6 +83,9 @@ "@edit_match": { "description": "Button & Appbar label for editing a match" }, + "@enter_points": { + "description": "Label to enter players points" + }, "@enter_results": { "description": "Button text to enter match results" }, @@ -232,6 +235,9 @@ } } }, + "@points": { + "description": "Points label" + }, "@privacy_policy": { "description": "Privacy policy menu item" }, @@ -271,6 +277,9 @@ "@select_winner": { "description": "Label to select the winner" }, + "@select_loser": { + "description": "Label to select the loser" + }, "@selected_players": { "description": "Shows the number of selected players" }, @@ -351,6 +360,7 @@ "delete_match": "Delete Match", "edit_group": "Edit Group", "edit_match": "Edit Match", + "enter_points": "Enter points", "enter_results": "Enter Results", "error_creating_group": "Error while creating group, please try again", "error_deleting_group": "Error while deleting group, please try again", @@ -399,6 +409,8 @@ "player_name": "Player name", "players": "Players", "players_count": "{count} Players", + "point": "Point", + "points": "Points", "privacy_policy": "Privacy Policy", "quick_create": "Quick Create", "recent_matches": "Recent Matches", @@ -411,12 +423,14 @@ "save_changes": "Save Changes", "search_for_groups": "Search for groups", "search_for_players": "Search for players", - "select_winner": "Select Winner:", + "select_winner": "Select Winner", + "select_loser": "Select Loser", "selected_players": "Selected players", "settings": "Settings", "single_loser": "Single Loser", "single_winner": "Single Winner", "highest_score": "Highest Score", + "loser": "Loser", "lowest_score": "Lowest Score", "multiple_winners": "Multiple Winners", "statistics": "Statistics", @@ -424,6 +438,7 @@ "successfully_added_player": "Successfully added player {playerName}", "there_is_no_group_matching_your_search": "There is no group matching your search", "this_cannot_be_undone": "This can't be undone.", + "tie": "Tie", "today_at": "Today at", "undo": "Undo", "unknown_exception": "Unknown Exception (see console)", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index eb8a609..99c9317 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -254,6 +254,12 @@ abstract class AppLocalizations { /// **'Edit Match'** String get edit_match; + /// Label to enter players points + /// + /// In en, this message translates to: + /// **'Enter points'** + String get enter_points; + /// Button text to enter match results /// /// In en, this message translates to: @@ -542,6 +548,18 @@ abstract class AppLocalizations { /// **'{count} Players'** String players_count(int count); + /// No description provided for @point. + /// + /// In en, this message translates to: + /// **'Point'** + String get point; + + /// Points label + /// + /// In en, this message translates to: + /// **'Points'** + String get points; + /// Privacy policy menu item /// /// In en, this message translates to: @@ -617,9 +635,15 @@ abstract class AppLocalizations { /// Label to select the winner /// /// In en, this message translates to: - /// **'Select Winner:'** + /// **'Select Winner'** String get select_winner; + /// Label to select the loser + /// + /// In en, this message translates to: + /// **'Select Loser'** + String get select_loser; + /// Shows the number of selected players /// /// In en, this message translates to: @@ -650,6 +674,12 @@ abstract class AppLocalizations { /// **'Highest Score'** String get highest_score; + /// No description provided for @loser. + /// + /// In en, this message translates to: + /// **'Loser'** + String get loser; + /// No description provided for @lowest_score. /// /// In en, this message translates to: @@ -692,6 +722,12 @@ abstract class AppLocalizations { /// **'This can\'t be undone.'** String get this_cannot_be_undone; + /// No description provided for @tie. + /// + /// In en, this message translates to: + /// **'Tie'** + String get tie; + /// Date format for today /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 501f9c6..51b4c62 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -79,7 +79,7 @@ class AppLocalizationsDe extends AppLocalizations { String get delete_all_data => 'Alle Daten löschen'; @override - String get delete_group => 'Diese Gruppe löschen'; + String get delete_group => 'Gruppe löschen'; @override String get delete_match => 'Spiel löschen'; @@ -90,6 +90,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get edit_match => 'Gruppe bearbeiten'; + @override + String get enter_points => 'Punkte eingeben'; + @override String get enter_results => 'Ergebnisse eintragen'; @@ -240,6 +243,12 @@ class AppLocalizationsDe extends AppLocalizations { return '$count Spieler'; } + @override + String get point => 'Punkt'; + + @override + String get points => 'Punkte'; + @override String get privacy_policy => 'Datenschutzerklärung'; @@ -281,7 +290,10 @@ class AppLocalizationsDe extends AppLocalizations { String get search_for_players => 'Nach Spieler:innen suchen'; @override - String get select_winner => 'Gewinner:in wählen:'; + String get select_winner => 'Gewinner:in wählen'; + + @override + String get select_loser => 'Verlierer:in wählen'; @override String get selected_players => 'Ausgewählte Spieler:innen'; @@ -298,6 +310,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get highest_score => 'Höchste Punkte'; + @override + String get loser => 'Verlierer:in'; + @override String get lowest_score => 'Niedrigste Punkte'; @@ -323,6 +338,9 @@ class AppLocalizationsDe extends AppLocalizations { String get this_cannot_be_undone => 'Dies kann nicht rückgängig gemacht werden.'; + @override + String get tie => 'Unentschieden'; + @override String get today_at => 'Heute um'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index cdebc69..2b42e47 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -90,6 +90,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get edit_match => 'Edit Match'; + @override + String get enter_points => 'Enter points'; + @override String get enter_results => 'Enter Results'; @@ -240,6 +243,12 @@ class AppLocalizationsEn extends AppLocalizations { return '$count Players'; } + @override + String get point => 'Point'; + + @override + String get points => 'Points'; + @override String get privacy_policy => 'Privacy Policy'; @@ -281,7 +290,10 @@ class AppLocalizationsEn extends AppLocalizations { String get search_for_players => 'Search for players'; @override - String get select_winner => 'Select Winner:'; + String get select_winner => 'Select Winner'; + + @override + String get select_loser => 'Select Loser'; @override String get selected_players => 'Selected players'; @@ -298,6 +310,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get highest_score => 'Highest Score'; + @override + String get loser => 'Loser'; + @override String get lowest_score => 'Lowest Score'; @@ -322,6 +337,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get this_cannot_be_undone => 'This can\'t be undone.'; + @override + String get tie => 'Tie'; + @override String get today_at => 'Today at'; diff --git a/lib/presentation/views/main_menu/group_view/create_group_view.dart b/lib/presentation/views/main_menu/group_view/create_group_view.dart index d5ac6a4..f88e2db 100644 --- a/lib/presentation/views/main_menu/group_view/create_group_view.dart +++ b/lib/presentation/views/main_menu/group_view/create_group_view.dart @@ -148,7 +148,7 @@ class _CreateGroupViewState extends State { final groupName = _groupNameController.text.trim(); final success = await db.groupDao.addGroup( - group: Group(name: groupName, description: '', members: selectedPlayers), + group: Group(name: groupName, members: selectedPlayers), ); return success; diff --git a/lib/presentation/views/main_menu/group_view/group_detail_view.dart b/lib/presentation/views/main_menu/group_view/group_detail_view.dart index 1ef89ef..92c3bba 100644 --- a/lib/presentation/views/main_menu/group_view/group_detail_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_detail_view.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:tallee/core/adaptive_page_route.dart'; +import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/match.dart'; @@ -10,10 +12,10 @@ import 'package:tallee/data/models/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/group_view/create_group_view.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart'; -import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/colored_icon_container.dart'; -import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart'; import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -70,23 +72,16 @@ class _GroupDetailViewState extends State { context: context, builder: (context) => CustomAlertDialog( title: '${loc.delete_group}?', - content: loc.this_cannot_be_undone, + content: Text(loc.this_cannot_be_undone), actions: [ - AnimatedDialogButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - loc.cancel, - style: const TextStyle(color: CustomTheme.textColor), - ), - ), - AnimatedDialogButton( + CustomDialogAction( onPressed: () => Navigator.of(context).pop(true), - child: Text( - loc.delete, - style: const TextStyle( - color: CustomTheme.secondaryColor, - ), - ), + text: loc.delete, + ), + CustomDialogAction( + onPressed: () => Navigator.of(context).pop(false), + buttonType: ButtonType.secondary, + text: loc.cancel, ), ], ), @@ -153,6 +148,7 @@ class _GroupDetailViewState extends State { children: _group.members.map((member) { return TextIconTile( text: member.name, + suffixText: getNameCountText(member), iconEnabled: false, ); }).toList(), @@ -259,28 +255,37 @@ class _GroupDetailViewState extends State { /// Determines the best player in the group based on match wins String _getBestPlayer(List matches) { - final bestPlayerCounts = {}; + final mvpCounts = {}; - // Count wins for each player for (var match in matches) { - if (match.winner != null && - _group.members.any((m) => m.id == match.winner?.id)) { - print(match.winner); - bestPlayerCounts.update( - match.winner!, - (value) => value + 1, - ifAbsent: () => 1, - ); + final mvps = match.mvp; + for (final mvpPlayer in mvps) { + if (_group.members.any((m) => m.id == mvpPlayer.id)) { + mvpCounts.update(mvpPlayer, (value) => value + 1, ifAbsent: () => 1); + } } } - // Sort players by win count - final sortedPlayers = bestPlayerCounts.entries.toList() + final sortedMvps = mvpCounts.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); - // Get the best player - bestPlayer = sortedPlayers.isNotEmpty ? sortedPlayers.first.key.name : '-'; + if (sortedMvps.isEmpty) { + return '-'; + } - return bestPlayer; + // Check if there are multiple players with the same value + final highestMvpCount = sortedMvps.first.value; + final topPlayers = sortedMvps + .where((entry) => entry.value == highestMvpCount) + .toList(); + switch (topPlayers.length) { + case 0: + return '-'; + case 1: + return topPlayers.first.key.name; + default: + final loc = AppLocalizations.of(context); + return loc.tie; + } } } diff --git a/lib/presentation/views/main_menu/group_view/group_view.dart b/lib/presentation/views/main_menu/group_view/group_view.dart index b091541..c8a9398 100644 --- a/lib/presentation/views/main_menu/group_view/group_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_view.dart @@ -36,7 +36,7 @@ class _GroupViewState extends State { Group( name: 'Skeleton Group', description: '', - members: List.filled(6, Player(name: 'Skeleton Player', description: '')), + members: List.filled(6, Player(name: 'Skeleton Player')), ), ); diff --git a/lib/presentation/views/main_menu/home_view.dart b/lib/presentation/views/main_menu/home_view.dart index 09cec54..321f12b 100644 --- a/lib/presentation/views/main_menu/home_view.dart +++ b/lib/presentation/views/main_menu/home_view.dart @@ -43,21 +43,41 @@ class _HomeViewState extends State { Match( name: 'Skeleton Match', game: Game( - name: '', + name: 'Skeleton Game', ruleset: Ruleset.singleWinner, - description: '', + description: 'This is a skeleton game description.', color: GameColor.blue, icon: '', ), group: Group( name: 'Skeleton Group', - description: '', + description: 'This is a skeleton group description.', members: [ - Player(name: 'Skeleton Player 1', description: ''), - Player(name: 'Skeleton Player 2', description: ''), + Player( + name: + 'Skeleton Player 1' + '', + ), + Player( + name: + 'Skeleton Player 2' + '', + ), ], ), - notes: '', + notes: 'These are skeleton notes.', + players: [ + Player( + name: + 'Skeleton Player 1' + '', + ), + Player( + name: + 'Skeleton Player 2' + '', + ), + ], ), ); @@ -125,7 +145,11 @@ class _HomeViewState extends State { MatchResultView(match: match), ), ); - await updatedWinnerInRecentMatches(match.id); + await loadRecentMatches(); + + setState(() { + print('loaded'); + }); }, ), ) @@ -224,15 +248,12 @@ class _HomeViewState extends State { }); } - /// Updates the winner information for a specific match in the recent matches list. - Future updatedWinnerInRecentMatches(String matchId) async { + Future loadRecentMatches() async { final db = Provider.of(context, listen: false); - final winner = await db.scoreEntryDao.getWinner(matchId: matchId); - final matchIndex = recentMatches.indexWhere((match) => match.id == matchId); - if (matchIndex != -1) { - setState(() { - recentMatches[matchIndex].winner = winner; - }); - } + final matches = await db.matchDao.getAllMatches(); + recentMatches = + (matches..sort((a, b) => b.createdAt.compareTo(a.createdAt))) + .take(2) + .toList(); } } diff --git a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart index d4d7f4d..51512f9 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; -import 'package:tallee/core/enums.dart'; +import 'package:tallee/data/models/game.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart'; import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart'; @@ -13,14 +13,14 @@ class ChooseGameView extends StatefulWidget { const ChooseGameView({ super.key, required this.games, - required this.initialGameIndex, + required this.initialGameId, }); /// A list of tuples containing the game name, description and ruleset - final List<(String, String, Ruleset)> games; + final List games; - /// The index of the initially selected game - final int initialGameIndex; + /// The id of the initially selected game + final String initialGameId; @override State createState() => _ChooseGameViewState(); @@ -31,11 +31,11 @@ class _ChooseGameViewState extends State { final TextEditingController searchBarController = TextEditingController(); /// Currently selected game index - late int selectedGameIndex; + late String selectedGameId; @override void initState() { - selectedGameIndex = widget.initialGameIndex; + selectedGameId = widget.initialGameId; super.initState(); } @@ -49,7 +49,13 @@ class _ChooseGameViewState extends State { leading: IconButton( icon: const Icon(Icons.arrow_back_ios), onPressed: () { - Navigator.of(context).pop(selectedGameIndex); + Navigator.of(context).pop( + selectedGameId == '' + ? null + : widget.games.firstWhere( + (game) => game.id == selectedGameId, + ), + ); }, ), title: Text(loc.choose_game), @@ -62,7 +68,7 @@ class _ChooseGameViewState extends State { if (didPop) { return; } - Navigator.of(context).pop(selectedGameIndex); + Navigator.of(context).pop(widget.initialGameId); }, child: Column( children: [ @@ -79,19 +85,19 @@ class _ChooseGameViewState extends State { itemCount: widget.games.length, itemBuilder: (BuildContext context, int index) { return TitleDescriptionListTile( - title: widget.games[index].$1, - description: widget.games[index].$2, + title: widget.games[index].name, + description: widget.games[index].description, badgeText: translateRulesetToString( - widget.games[index].$3, + widget.games[index].ruleset, context, ), - isHighlighted: selectedGameIndex == index, + isHighlighted: selectedGameId == widget.games[index].id, onPressed: () async { setState(() { - if (selectedGameIndex == index) { - selectedGameIndex = -1; + if (selectedGameId != widget.games[index].id) { + selectedGameId = widget.games[index].id; } else { - selectedGameIndex = index; + selectedGameId = ''; } }); }, diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart index 950b3a8..1a04c78 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart @@ -20,7 +20,9 @@ import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; class CreateMatchView extends StatefulWidget { /// A view that allows creating a new match - /// [onWinnerChanged]: Optional callback invoked when the winner is changed + /// - [onWinnerChanged]: Optional callback invoked when the winner is changed + /// - [matchToEdit]: An optional match to prefill the fields for editing. + /// - [onMatchUpdated]: Optional callback invoked when the match is updated (only in const CreateMatchView({ super.key, this.onWinnerChanged, @@ -28,13 +30,11 @@ class CreateMatchView extends StatefulWidget { this.onMatchUpdated, }); - /// Optional callback invoked when the winner is changed final VoidCallback? onWinnerChanged; - /// Optional callback invoked when the match is updated final void Function(Match)? onMatchUpdated; - /// An optional match to prefill the fields + /// An optional match to prefill the fields for editing. final Match? matchToEdit; @override @@ -50,20 +50,12 @@ class _CreateMatchViewState extends State { /// Hint text for the match name input field String? hintText; - /// List of all groups from the database List groupsList = []; - - /// List of all players from the database List playerList = []; + List gamesList = []; - /// The currently selected group Group? selectedGroup; - - /// The index of the currently selected game in [games] to mark it in - /// the [ChooseGameView] - int selectedGameIndex = -1; - - /// The currently selected players + Game? selectedGame; List selectedPlayers = []; /// GlobalKey for ScaffoldMessenger to show snackbars @@ -81,12 +73,14 @@ class _CreateMatchViewState extends State { Future.wait([ db.groupDao.getAllGroups(), db.playerDao.getAllPlayers(), + db.gameDao.getAllGames(), ]).then((result) async { groupsList = result[0] as List; playerList = result[1] as List; + gamesList = (result[2] as List); // If a match is provided, prefill the fields - if (widget.matchToEdit != null) { + if (isEditMode()) { prefillMatchDetails(); } }); @@ -105,20 +99,11 @@ class _CreateMatchViewState extends State { hintText ??= loc.match_name; } - List<(String, String, Ruleset)> games = [ - ('Example Game 1', 'This is a description', Ruleset.lowestScore), - ('Example Game 2', '', Ruleset.singleWinner), - ]; - @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); - final buttonText = widget.matchToEdit != null - ? loc.save_changes - : loc.create_match; - final viewTitle = widget.matchToEdit != null - ? loc.edit_match - : loc.create_new_match; + final buttonText = isEditMode() ? loc.save_changes : loc.create_match; + final viewTitle = isEditMode() ? loc.edit_match : loc.create_new_match; return ScaffoldMessenger( key: _scaffoldMessengerKey, @@ -140,21 +125,21 @@ class _CreateMatchViewState extends State { ), ChooseTile( title: loc.game, - trailingText: selectedGameIndex == -1 - ? loc.none - : games[selectedGameIndex].$1, + trailingText: selectedGame == null + ? loc.none_group + : selectedGame!.name, onPressed: () async { - selectedGameIndex = await Navigator.of(context).push( + selectedGame = await Navigator.of(context).push( adaptivePageRoute( builder: (context) => ChooseGameView( - games: games, - initialGameIndex: selectedGameIndex, + games: gamesList, + initialGameId: selectedGame?.id ?? '', ), ), ); setState(() { - if (selectedGameIndex != -1) { - hintText = games[selectedGameIndex].$1; + if (selectedGame != null) { + hintText = selectedGame!.name; } else { hintText = loc.match_name; } @@ -225,6 +210,10 @@ class _CreateMatchViewState extends State { ); } + bool isEditMode() { + return widget.matchToEdit != null; + } + /// Determines whether the "Create Match" button should be enabled. /// /// Returns `true` if: @@ -232,7 +221,7 @@ class _CreateMatchViewState extends State { /// - Either a group is selected OR at least 2 players are selected bool _enableCreateGameButton() { return (selectedGroup != null || - (selectedPlayers.length > 1) && selectedGameIndex != -1); + (selectedPlayers.length > 1) && selectedGame != null); } // If a match was provided to the view, it updates the match in the database @@ -240,7 +229,7 @@ class _CreateMatchViewState extends State { // If no match was provided, it creates a new match in the database and // navigates to the MatchResultView for the newly created match. void buttonNavigation(BuildContext context) async { - if (widget.matchToEdit != null) { + if (isEditMode()) { await updateMatch(); if (context.mounted) { Navigator.pop(context); @@ -266,9 +255,6 @@ class _CreateMatchViewState extends State { /// Updates attributes of the existing match in the database based on the /// changes made in the edit view. Future updateMatch() async { - //TODO: Remove when Games implemented - final tempGame = await getTemporaryGame(); - final updatedMatch = Match( id: widget.matchToEdit!.id, name: _matchNameController.text.isEmpty @@ -276,8 +262,7 @@ class _CreateMatchViewState extends State { : _matchNameController.text.trim(), group: selectedGroup, players: selectedPlayers, - game: tempGame, - winner: widget.matchToEdit!.winner, + game: widget.matchToEdit!.game, createdAt: widget.matchToEdit!.createdAt, endedAt: widget.matchToEdit!.endedAt, notes: widget.matchToEdit!.notes, @@ -314,9 +299,6 @@ class _CreateMatchViewState extends State { matchId: widget.matchToEdit!.id, playerId: player.id, ); - if (widget.matchToEdit!.winner?.id == player.id) { - updatedMatch.winner = null; - } } } @@ -326,8 +308,6 @@ class _CreateMatchViewState extends State { // Creates a new match and adds it to the database. // Returns the created match. Future createMatch() async { - final tempGame = await getTemporaryGame(); - Match match = Match( name: _matchNameController.text.isEmpty ? (hintText ?? '') @@ -335,35 +315,18 @@ class _CreateMatchViewState extends State { createdAt: DateTime.now(), group: selectedGroup, players: selectedPlayers, - game: tempGame, + game: selectedGame!, ); await db.matchDao.addMatch(match: match); return match; } - // TODO: Remove when games fully implemented - Future getTemporaryGame() async { - Game? game; - - final selectedGame = games[selectedGameIndex]; - game = Game( - name: selectedGame.$1, - description: selectedGame.$2, - ruleset: selectedGame.$3, - color: GameColor.blue, - icon: '', - ); - - await db.gameDao.addGame(game: game); - return game; - } - // If a match was provided to the view, this method prefills the input fields void prefillMatchDetails() { final match = widget.matchToEdit!; _matchNameController.text = match.name; selectedPlayers = match.players; - selectedGameIndex = 0; + selectedGame = match.game; if (match.group != null) { selectedGroup = match.group; diff --git a/lib/presentation/views/main_menu/match_view/match_detail_view.dart b/lib/presentation/views/main_menu/match_view/match_detail_view.dart index fc53aa8..2117b77 100644 --- a/lib/presentation/views/main_menu/match_view/match_detail_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_detail_view.dart @@ -4,15 +4,16 @@ import 'package:provider/provider.dart'; import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_match_view.dart'; import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; -import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/colored_icon_container.dart'; -import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart'; import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -64,23 +65,16 @@ class _MatchDetailViewState extends State { context: context, builder: (context) => CustomAlertDialog( title: '${loc.delete_match}?', - content: loc.this_cannot_be_undone, + content: Text(loc.this_cannot_be_undone), actions: [ - AnimatedDialogButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - loc.cancel, - style: const TextStyle(color: CustomTheme.textColor), - ), - ), - AnimatedDialogButton( + CustomDialogAction( onPressed: () => Navigator.of(context).pop(true), - child: Text( - loc.delete, - style: const TextStyle( - color: CustomTheme.secondaryColor, - ), - ), + text: loc.delete, + ), + CustomDialogAction( + onPressed: () => Navigator.of(context).pop(false), + buttonType: ButtonType.secondary, + text: loc.cancel, ), ], ), @@ -161,6 +155,7 @@ class _MatchDetailViewState extends State { children: match.players.map((player) { return TextIconTile( text: player.name, + suffixText: getNameCountText(player), iconEnabled: false, ); }).toList(), @@ -175,37 +170,7 @@ class _MatchDetailViewState extends State { vertical: 4, horizontal: 8, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - /// TODO: Implement different ruleset results display - if (match.winner != null) ...[ - Text( - loc.winner, - style: const TextStyle( - fontSize: 16, - color: CustomTheme.textColor, - ), - ), - Text( - match.winner!.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: CustomTheme.primaryColor, - ), - ), - ] else ...[ - Text( - loc.no_results_entered_yet, - style: const TextStyle( - fontSize: 14, - color: CustomTheme.textColor, - ), - ), - ], - ], - ), + child: getResultWidget(loc), ), ), ], @@ -232,7 +197,7 @@ class _MatchDetailViewState extends State { text: loc.enter_results, icon: Icons.emoji_events, onPressed: () async { - match.winner = await Navigator.push( + await Navigator.push( context, adaptivePageRoute( fullscreenDialog: true, @@ -264,4 +229,108 @@ class _MatchDetailViewState extends State { }); widget.onMatchUpdate.call(); } + + /// Returns the widget to be displayed in the result [InfoTile] + Widget getResultWidget(AppLocalizations loc) { + if (isSingleRowResult()) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: getSingleResultRow(loc), + ); + } else { + return getScoreResultWidget(loc); + } + } + + /// Returns the result row for single winner/loser rulesets or a placeholder + /// if no result is entered yet + List getSingleResultRow(AppLocalizations loc) { + // Single Winner + if (match.mvp.isNotEmpty && match.game.ruleset == Ruleset.singleWinner) { + return [ + Text( + loc.winner, + style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), + ), + Text( + match.mvp.first.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ]; + // Single Loser + } else if (match.game.ruleset == Ruleset.singleLoser) { + return [ + Text( + loc.loser, + style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), + ), + Text( + match.mvp.first.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ]; + // No result entered yet + } else { + return [ + Text( + loc.no_results_entered_yet, + style: const TextStyle(fontSize: 14, color: CustomTheme.textColor), + ), + ]; + } + } + + /// Returns the result widget for scores + Widget getScoreResultWidget(AppLocalizations loc) { + List<(String, int)> playerScores = []; + for (var player in match.players) { + int score = match.scores[player.id]?.score ?? 0; + playerScores.add((player.name, score)); + } + if (widget.match.game.ruleset == Ruleset.highestScore) { + playerScores.sort((a, b) => b.$2.compareTo(a.$2)); + } else if (widget.match.game.ruleset == Ruleset.lowestScore) { + playerScores.sort((a, b) => a.$2.compareTo(b.$2)); + } + + return Column( + children: [ + for (var score in playerScores) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + score.$1, + style: const TextStyle( + fontSize: 16, + color: CustomTheme.textColor, + ), + ), + Text( + getPointLabel(loc, score.$2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ], + ), + ], + ); + } + + // Returns if the result can be displayed in a single row + bool isSingleRowResult() { + return match.game.ruleset == Ruleset.singleWinner || + match.game.ruleset == Ruleset.singleLoser; + } } diff --git a/lib/presentation/views/main_menu/match_view/match_result_view.dart b/lib/presentation/views/main_menu/match_view/match_result_view.dart index d677e43..8b41920 100644 --- a/lib/presentation/views/main_menu/match_view/match_result_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_result_view.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/player.dart'; +import 'package:tallee/data/models/score_entry.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; import 'package:tallee/presentation/widgets/tiles/custom_radio_list_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/score_list_tile.dart'; class MatchResultView extends StatefulWidget { /// A view that allows selecting and saving the winner of a match @@ -26,30 +30,61 @@ class MatchResultView extends StatefulWidget { class _MatchResultViewState extends State { late final AppDatabase db; + late final Ruleset ruleset; + /// List of all players who participated in the match late final List allPlayers; + /// List of text controllers for score entry, one for each player + late final List controller; + + late bool canSave; + /// Currently selected winner player Player? _selectedPlayer; @override void initState() { db = Provider.of(context, listen: false); + ruleset = widget.match.game.ruleset; + canSave = !rulesetSupportsScoreEntry(); allPlayers = widget.match.players; allPlayers.sort((a, b) => a.name.compareTo(b.name)); - if (widget.match.winner != null) { - _selectedPlayer = allPlayers.firstWhere( - (p) => p.id == widget.match.winner!.id, - ); + controller = List.generate( + allPlayers.length, + (index) => TextEditingController()..addListener(() => onTextEnter()), + ); + + if (widget.match.mvp.isNotEmpty) { + if (rulesetSupportsWinnerSelection()) { + _selectedPlayer = allPlayers.firstWhere( + (p) => p.id == widget.match.mvp.first.id, + ); + } else if (rulesetSupportsScoreEntry()) { + for (int i = 0; i < allPlayers.length; i++) { + final scoreList = widget.match.scores[allPlayers[i].id]; + final score = scoreList?.score ?? 0; + controller[i].text = score.toString(); + } + } + super.initState(); } - super.initState(); + } + + @override + void dispose() { + for (final c in controller) { + c.dispose(); + } + super.dispose(); } @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); + return Scaffold( backgroundColor: CustomTheme.backgroundColor, appBar: AppBar( @@ -85,67 +120,169 @@ class _MatchResultViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - loc.select_winner, + '${getTitleForRuleset(loc)}:', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 10), - Expanded( - child: RadioGroup( - groupValue: _selectedPlayer, - onChanged: (Player? value) async { - setState(() { - _selectedPlayer = value; - }); - await _handleWinnerSaving(); - }, - child: ListView.builder( + if (rulesetSupportsWinnerSelection()) + Expanded( + child: RadioGroup( + groupValue: _selectedPlayer, + onChanged: (Player? value) async { + setState(() { + _selectedPlayer = value; + }); + }, + child: ListView.builder( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return CustomRadioListTile( + text: allPlayers[index].name, + value: allPlayers[index], + onContainerTap: (value) async { + setState(() { + // Check if the already selected player is the same as the newly tapped player. + if (_selectedPlayer == value) { + // If yes deselected the player by setting it to null. + _selectedPlayer = null; + } else { + // If no assign the newly tapped player to the selected player. + (_selectedPlayer = value); + } + }); + }, + ); + }, + ), + ), + ), + if (rulesetSupportsScoreEntry()) + Expanded( + child: ListView.separated( itemCount: allPlayers.length, itemBuilder: (context, index) { - return CustomRadioListTile( + return ScoreListTile( text: allPlayers[index].name, - value: allPlayers[index], - onContainerTap: (value) async { - setState(() { - // Check if the already selected player is the same as the newly tapped player. - if (_selectedPlayer == value) { - // If yes deselected the player by setting it to null. - _selectedPlayer = null; - } else { - // If no assign the newly tapped player to the selected player. - (_selectedPlayer = value); - } - }); - await _handleWinnerSaving(); - }, + controller: controller[index], + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Divider(indent: 20), ); }, ), ), - ), ], ), ), ), + CustomWidthButton( + text: loc.save_changes, + sizeRelativeToWidth: 0.95, + onPressed: canSave + ? () async { + final ending = DateTime.now(); + await db.matchDao.updateMatchEndedAt( + matchId: widget.match.id, + endedAt: ending, + ); + await _handleSaving(); + if (!context.mounted) return; + Navigator.of(context).pop(_selectedPlayer); + } + : null, + ), ], ), ), ); } + /// Updated [canSave] everytime a text is entered in one of the score entry fields. + void onTextEnter() { + if (rulesetSupportsScoreEntry()) { + setState(() { + canSave = controller.every((c) => c.text.isNotEmpty); + }); + } + } + /// Handles saving or removing the winner in the database /// based on the current selection. - Future _handleWinnerSaving() async { + Future _handleSaving() async { + if (ruleset == Ruleset.singleWinner) { + await _handleWinner(); + } else if (ruleset == Ruleset.singleLoser) { + await _handleLoser(); + } else if (ruleset == Ruleset.lowestScore || + ruleset == Ruleset.highestScore) { + await _handleScores(); + } + + widget.onWinnerChanged?.call(); + } + + /// Handles saving or removing the winner in the database. + Future _handleWinner() async { if (_selectedPlayer == null) { - await db.scoreEntryDao.removeWinner(matchId: widget.match.id); + return await db.scoreEntryDao.removeWinner(matchId: widget.match.id); } else { - await db.scoreEntryDao.setWinner( + return await db.scoreEntryDao.setWinner( matchId: widget.match.id, playerId: _selectedPlayer!.id, ); } - widget.onWinnerChanged?.call(); + } + + /// Handles saving or removing the loser in the database. + Future _handleLoser() async { + if (_selectedPlayer == null) { + return await db.scoreEntryDao.removeLooser(matchId: widget.match.id); + } else { + return await db.scoreEntryDao.setLooser( + matchId: widget.match.id, + playerId: _selectedPlayer!.id, + ); + } + } + + /// Handles saving the scores for each player in the database. + Future _handleScores() async { + for (int i = 0; i < allPlayers.length; i++) { + var text = controller[i].text; + if (text.isEmpty) { + text = '0'; + } + final score = int.parse(text); + await db.scoreEntryDao.addScore( + matchId: widget.match.id, + playerId: allPlayers[i].id, + entry: ScoreEntry(roundNumber: 0, score: score, change: 0), + ); + } + } + + String getTitleForRuleset(AppLocalizations loc) { + switch (ruleset) { + case Ruleset.singleWinner: + return loc.select_winner; + case Ruleset.singleLoser: + return loc.select_loser; + default: + return loc.enter_points; + } + } + + bool rulesetSupportsWinnerSelection() { + return ruleset == Ruleset.singleWinner || ruleset == Ruleset.singleLoser; + } + + bool rulesetSupportsScoreEntry() { + return ruleset == Ruleset.lowestScore || ruleset == Ruleset.highestScore; } } diff --git a/lib/presentation/views/main_menu/match_view/match_view.dart b/lib/presentation/views/main_menu/match_view/match_view.dart index 1a202c4..2fb36e7 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -37,20 +37,16 @@ class _MatchViewState extends State { Match( name: 'Skeleton match name', game: Game( - name: '', + name: 'Game name', ruleset: Ruleset.singleWinner, - description: '', color: GameColor.blue, icon: '', ), group: Group( name: 'Group name', - description: '', - members: List.filled(5, Player(name: 'Player', description: '')), + members: List.filled(5, Player(name: 'Player')), ), - winner: Player(name: 'Player', description: ''), - players: [Player(name: 'Player', description: '')], - notes: '', + players: [Player(name: 'Player')], ), ); @@ -116,7 +112,7 @@ class _MatchViewState extends State { Positioned( bottom: MediaQuery.paddingOf(context).bottom + 20, child: MainMenuButton( - text: 'Spiel erstellen', + text: loc.create_match, icon: RpgAwesome.clovers_card, onPressed: () async { Navigator.push( diff --git a/lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart b/lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart index cafec67..8811411 100644 --- a/lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart +++ b/lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart @@ -30,7 +30,6 @@ const allDependencies = [ _cli_util, _clock, _code_assets, - _code_builder, _collection, _convert, _coverage, @@ -109,6 +108,7 @@ const allDependencies = [ _pubspec_parse, _quiver, _recase, + _record_use, _retry, _rfc_6901, _safe_url_check, @@ -159,14 +159,14 @@ const allDependencies = [ /// Direct `dependencies`. const dependencies = [ - _flutter, - _flutter_localizations, _clock, _cupertino_icons, _drift, _drift_flutter, _file_picker, _file_saver, + _flutter, + _flutter_localizations, _fluttericon, _font_awesome_flutter, _intl, @@ -567,17 +567,17 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', ); -/// build_runner 2.13.1 +/// build_runner 2.14.0 const _build_runner = Package( name: 'build_runner', description: 'A build system for Dart code generation and modular compilation.', repository: 'https://github.com/dart-lang/build/tree/master/build_runner', authors: [], - version: '2.13.1', + version: '2.14.0', spdxIdentifiers: ['BSD-3-Clause'], isMarkdown: false, isSdk: false, - dependencies: [PackageRef('analyzer'), PackageRef('args'), PackageRef('async'), PackageRef('build'), PackageRef('build_config'), PackageRef('build_daemon'), PackageRef('built_collection'), PackageRef('built_value'), PackageRef('code_builder'), PackageRef('collection'), PackageRef('convert'), PackageRef('crypto'), PackageRef('dart_style'), PackageRef('glob'), PackageRef('graphs'), PackageRef('http_multi_server'), PackageRef('io'), PackageRef('json_annotation'), PackageRef('logging'), PackageRef('meta'), PackageRef('mime'), PackageRef('package_config'), PackageRef('path'), PackageRef('pool'), PackageRef('pub_semver'), PackageRef('shelf'), PackageRef('shelf_web_socket'), PackageRef('stream_transform'), PackageRef('watcher'), PackageRef('web_socket_channel'), PackageRef('yaml')], + dependencies: [PackageRef('analyzer'), PackageRef('args'), PackageRef('async'), PackageRef('build'), PackageRef('build_config'), PackageRef('build_daemon'), PackageRef('built_collection'), PackageRef('built_value'), PackageRef('collection'), PackageRef('convert'), PackageRef('crypto'), PackageRef('dart_style'), PackageRef('glob'), PackageRef('graphs'), PackageRef('http_multi_server'), PackageRef('io'), PackageRef('json_annotation'), PackageRef('logging'), PackageRef('meta'), PackageRef('mime'), PackageRef('package_config'), PackageRef('path'), PackageRef('pool'), PackageRef('pub_semver'), PackageRef('shelf'), PackageRef('shelf_web_socket'), PackageRef('stream_transform'), PackageRef('watcher'), PackageRef('web_socket_channel'), PackageRef('yaml')], devDependencies: [PackageRef('stream_channel'), PackageRef('test')], license: '''Copyright 2016, the Dart project authors. @@ -1153,47 +1153,6 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', ); -/// code_builder 4.11.1 -const _code_builder = Package( - name: 'code_builder', - description: 'A fluent, builder-based library for generating valid Dart code.', - repository: 'https://github.com/dart-lang/tools/tree/main/pkgs/code_builder', - authors: [], - version: '4.11.1', - spdxIdentifiers: ['BSD-3-Clause'], - isMarkdown: false, - isSdk: false, - dependencies: [PackageRef('built_collection'), PackageRef('built_value'), PackageRef('collection'), PackageRef('matcher'), PackageRef('meta')], - devDependencies: [PackageRef('build'), PackageRef('build_runner'), PackageRef('dart_style'), PackageRef('source_gen'), PackageRef('test')], - license: '''Copyright 2016, the Dart project authors. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - * Neither the name of Google LLC nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', - ); - /// collection 1.19.1 const _collection = Package( name: 'collection', @@ -2418,14 +2377,14 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', ); -/// file_picker 10.3.10 +/// file_picker 11.0.2 const _file_picker = Package( name: 'file_picker', description: 'A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support.', homepage: 'https://github.com/miguelpruivo/plugins_flutter_file_picker', repository: 'https://github.com/miguelpruivo/flutter_file_picker', authors: [], - version: '10.3.10', + version: '11.0.2', spdxIdentifiers: ['MIT'], isMarkdown: false, isSdk: false, @@ -2731,13 +2690,13 @@ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', ); -/// font_awesome_flutter 10.12.0 +/// font_awesome_flutter 11.0.0 const _font_awesome_flutter = Package( name: 'font_awesome_flutter', description: 'The Font Awesome Icon pack available as Flutter Icons. Provides 2000 additional icons to use in your apps.', repository: 'https://github.com/fluttercommunity/font_awesome_flutter', authors: [], - version: '10.12.0', + version: '11.0.0', spdxIdentifiers: ['MIT'], isMarkdown: false, isSdk: false, @@ -2892,17 +2851,17 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', ); -/// hooks 1.0.2 +/// hooks 1.0.3 const _hooks = Package( name: 'hooks', description: 'A library that contains a Dart API for the JSON-based protocol for `hook/build.dart` and `hook/link.dart`.', repository: 'https://github.com/dart-lang/native/tree/main/pkgs/hooks', authors: [], - version: '1.0.2', + version: '1.0.3', spdxIdentifiers: ['BSD-3-Clause'], isMarkdown: false, isSdk: false, - dependencies: [PackageRef('collection'), PackageRef('crypto'), PackageRef('logging'), PackageRef('meta'), PackageRef('pub_semver'), PackageRef('yaml')], + dependencies: [PackageRef('collection'), PackageRef('crypto'), PackageRef('logging'), PackageRef('meta'), PackageRef('pub_semver'), PackageRef('record_use'), PackageRef('yaml')], devDependencies: [PackageRef('args'), PackageRef('code_assets'), PackageRef('glob'), PackageRef('json_schema'), PackageRef('path'), PackageRef('test')], license: '''Copyright 2025, the Dart project authors. @@ -5106,6 +5065,48 @@ Redistribution and use in source and binary forms, with or without modification, THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', ); +/// record_use 0.6.0 +const _record_use = Package( + name: 'record_use', + description: '''The serialization logic and API for the usage recording SDK feature. +''', + repository: 'https://github.com/dart-lang/native/tree/main/pkgs/record_use', + authors: [], + version: '0.6.0', + spdxIdentifiers: ['BSD-3-Clause'], + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('collection'), PackageRef('meta'), PackageRef('pub_semver')], + devDependencies: [PackageRef('json_schema'), PackageRef('test')], + license: '''Copyright 2024, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + ); + /// retry 3.1.2 const _retry = Package( name: 'retry', @@ -37480,13 +37481,13 @@ freely, subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution.''', ); -/// vm_service 15.0.2 +/// vm_service 15.1.0 const _vm_service = Package( name: 'vm_service', description: 'A library to communicate with a service implementing the Dart VM service protocol.', repository: 'https://github.com/dart-lang/sdk/tree/main/pkg/vm_service', authors: [], - version: '15.0.2', + version: '15.1.0', spdxIdentifiers: ['BSD-3-Clause'], isMarkdown: false, isSdk: false, @@ -37880,16 +37881,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.''', ); -/// tallee 0.0.20+254 +/// tallee 0.0.23+257 const _tallee = Package( name: 'tallee', description: 'Tracking App for Card Games', authors: [], - version: '0.0.20+254', + version: '0.0.23+257', spdxIdentifiers: ['LGPL-3.0'], isMarkdown: false, isSdk: false, - dependencies: [PackageRef('flutter'), PackageRef('flutter_localizations'), PackageRef('clock'), PackageRef('cupertino_icons'), PackageRef('drift'), PackageRef('drift_flutter'), PackageRef('file_picker'), PackageRef('file_saver'), PackageRef('fluttericon'), PackageRef('font_awesome_flutter'), PackageRef('intl'), PackageRef('json_schema'), PackageRef('package_info_plus'), PackageRef('path_provider'), PackageRef('provider'), PackageRef('skeletonizer'), PackageRef('url_launcher'), PackageRef('uuid')], + dependencies: [PackageRef('clock'), PackageRef('cupertino_icons'), PackageRef('drift'), PackageRef('drift_flutter'), PackageRef('file_picker'), PackageRef('file_saver'), PackageRef('flutter'), PackageRef('flutter_localizations'), PackageRef('fluttericon'), PackageRef('font_awesome_flutter'), PackageRef('intl'), PackageRef('json_schema'), PackageRef('package_info_plus'), PackageRef('path_provider'), PackageRef('provider'), PackageRef('skeletonizer'), PackageRef('url_launcher'), PackageRef('uuid')], devDependencies: [PackageRef('flutter_test'), PackageRef('build_runner'), PackageRef('dart_pubspec_licenses'), PackageRef('drift_dev'), PackageRef('flutter_lints')], license: '''GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 diff --git a/lib/presentation/views/main_menu/settings_view/settings_view.dart b/lib/presentation/views/main_menu/settings_view/settings_view.dart index 6a558ad..8e1cbdc 100644 --- a/lib/presentation/views/main_menu/settings_view/settings_view.dart +++ b/lib/presentation/views/main_menu/settings_view/settings_view.dart @@ -9,8 +9,8 @@ import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/settings_view/licenses_view.dart'; -import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; -import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart'; import 'package:tallee/presentation/widgets/tiles/settings_list_tile.dart'; import 'package:tallee/services/data_transfer_service.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -122,25 +122,16 @@ class _SettingsViewState extends State { context: context, builder: (context) => CustomAlertDialog( title: '${loc.delete_all_data}?', - content: loc.this_cannot_be_undone, + content: Text(loc.this_cannot_be_undone), actions: [ - AnimatedDialogButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - loc.cancel, - style: const TextStyle( - color: CustomTheme.textColor, - ), - ), - ), - AnimatedDialogButton( + CustomDialogAction( onPressed: () => Navigator.of(context).pop(true), - child: Text( - loc.delete, - style: const TextStyle( - color: CustomTheme.secondaryColor, - ), - ), + text: loc.delete, + ), + CustomDialogAction( + onPressed: () => Navigator.of(context).pop(false), + buttonType: ButtonType.secondary, + text: loc.cancel, ), ], ), diff --git a/lib/presentation/views/main_menu/statistics_view.dart b/lib/presentation/views/main_menu/statistics_view.dart index 3a55115..98a8e1d 100644 --- a/lib/presentation/views/main_menu/statistics_view.dart +++ b/lib/presentation/views/main_menu/statistics_view.dart @@ -18,9 +18,18 @@ class StatisticsView extends StatefulWidget { } class _StatisticsViewState extends State { - List<(String, int)> winCounts = List.filled(6, ('Skeleton Player', 1)); - List<(String, int)> matchCounts = List.filled(6, ('Skeleton Player', 1)); - List<(String, double)> winRates = List.filled(6, ('Skeleton Player', 1)); + List<(Player, int)> winCounts = List.filled(6, ( + Player(name: 'Skeleton Player'), + 1, + )); + List<(Player, int)> matchCounts = List.filled(6, ( + Player(name: 'Skeleton Player'), + 1, + )); + List<(Player, double)> winRates = List.filled(6, ( + Player(name: 'Skeleton Player'), + 1, + )); bool isLoading = true; @override @@ -121,7 +130,10 @@ class _StatisticsViewState extends State { players: players, context: context, ); - winRates = computeWinRatePercent(wins: winCounts, matches: matchCounts); + winRates = computeWinRatePercent( + winCounts: winCounts, + matchCounts: matchCounts, + ); setState(() { isLoading = false; }); @@ -130,47 +142,46 @@ class _StatisticsViewState extends State { /// Calculates the number of wins for each player /// and returns a sorted list of tuples (playerName, winCount) - List<(String, int)> _calculateWinsForAllPlayers({ + List<(Player, int)> _calculateWinsForAllPlayers({ required List matches, required List players, required BuildContext context, }) { - List<(String, int)> winCounts = []; + List<(Player, int)> winCounts = []; final loc = AppLocalizations.of(context); // Getting the winners for (var match in matches) { - final winner = match.winner; - if (winner != null) { - final index = winCounts.indexWhere((entry) => entry.$1 == winner.id); + final mvps = match.mvp; + for (var winner in mvps) { + final index = winCounts.indexWhere((entry) => entry.$1.id == winner.id); // -1 means winner not found in winCounts if (index != -1) { final current = winCounts[index].$2; - winCounts[index] = (winner.id, current + 1); + winCounts[index] = (winner, current + 1); } else { - winCounts.add((winner.id, 1)); + winCounts.add((winner, 1)); } } } // Adding all players with zero wins for (var player in players) { - final index = winCounts.indexWhere((entry) => entry.$1 == player.id); + final index = winCounts.indexWhere((entry) => entry.$1.id == player.id); // -1 means player not found in winCounts if (index == -1) { - winCounts.add((player.id, 0)); + winCounts.add((player, 0)); } } // Replace player IDs with names for (int i = 0; i < winCounts.length; i++) { - final playerId = winCounts[i].$1; + final playerId = winCounts[i].$1.id; final player = players.firstWhere( (p) => p.id == playerId, - orElse: () => - Player(id: playerId, name: loc.not_available, description: ''), + orElse: () => Player(id: playerId, name: loc.not_available), ); - winCounts[i] = (player.name, winCounts[i].$2); + winCounts[i] = (player, winCounts[i].$2); } winCounts.sort((a, b) => b.$2.compareTo(a.$2)); @@ -180,60 +191,51 @@ class _StatisticsViewState extends State { /// Calculates the number of matches played for each player /// and returns a sorted list of tuples (playerName, matchCount) - List<(String, int)> _calculateMatchAmountsForAllPlayers({ + List<(Player, int)> _calculateMatchAmountsForAllPlayers({ required List matches, required List players, required BuildContext context, }) { - List<(String, int)> matchCounts = []; + List<(Player, int)> matchCounts = []; final loc = AppLocalizations.of(context); // Counting matches for each player for (var match in matches) { - if (match.group != null) { - final members = match.group!.members.map((p) => p.id).toList(); - for (var playerId in members) { - final index = matchCounts.indexWhere((entry) => entry.$1 == playerId); - // -1 means player not found in matchCounts - if (index != -1) { - final current = matchCounts[index].$2; - matchCounts[index] = (playerId, current + 1); - } else { - matchCounts.add((playerId, 1)); - } - } - } - final members = match.players.map((p) => p.id).toList(); - for (var playerId in members) { - final index = matchCounts.indexWhere((entry) => entry.$1 == playerId); - // -1 means player not found in matchCounts - if (index != -1) { - final current = matchCounts[index].$2; - matchCounts[index] = (playerId, current + 1); + for (Player player in match.players) { + // Check if the player is already in matchCounts + final index = matchCounts.indexWhere( + (entry) => entry.$1.id == player.id, + ); + + // -1 -> not found + if (index == -1) { + // Add new entry + matchCounts.add((player, 1)); } else { - matchCounts.add((playerId, 1)); + // Update existing entry + final currentMatchAmount = matchCounts[index].$2; + matchCounts[index] = (player, currentMatchAmount + 1); } } } // Adding all players with zero matches for (var player in players) { - final index = matchCounts.indexWhere((entry) => entry.$1 == player.id); + final index = matchCounts.indexWhere((entry) => entry.$1.id == player.id); // -1 means player not found in matchCounts if (index == -1) { - matchCounts.add((player.id, 0)); + matchCounts.add((player, 0)); } } // Replace player IDs with names for (int i = 0; i < matchCounts.length; i++) { - final playerId = matchCounts[i].$1; + final playerId = matchCounts[i].$1.id; final player = players.firstWhere( (p) => p.id == playerId, - orElse: () => - Player(id: playerId, name: loc.not_available, description: ''), + orElse: () => Player(id: playerId, name: loc.not_available), ); - matchCounts[i] = (player.name, matchCounts[i].$2); + matchCounts[i] = (player, matchCounts[i].$2); } matchCounts.sort((a, b) => b.$2.compareTo(a.$2)); @@ -241,25 +243,24 @@ class _StatisticsViewState extends State { return matchCounts; } - // dart - List<(String, double)> computeWinRatePercent({ - required List<(String, int)> wins, - required List<(String, int)> matches, + List<(Player, double)> computeWinRatePercent({ + required List<(Player, int)> winCounts, + required List<(Player, int)> matchCounts, }) { - final Map winsMap = {for (var e in wins) e.$1: e.$2}; - final Map matchesMap = {for (var e in matches) e.$1: e.$2}; + final Map winsMap = {for (var e in winCounts) e.$1: e.$2}; + final Map matchesMap = {for (var e in matchCounts) e.$1: e.$2}; // Get all unique player names - final names = {...winsMap.keys, ...matchesMap.keys}; + final player = {...matchesMap.keys}; // Calculate win rates - final result = names.map((name) { + final result = player.map((name) { final int w = winsMap[name] ?? 0; - final int g = matchesMap[name] ?? 0; + final int m = matchesMap[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)) + final double percent = (m > 0) + ? double.parse(((w / m)).toStringAsFixed(2)) : 0; return (name, percent); }).toList(); diff --git a/lib/presentation/widgets/buttons/animated_dialog_button.dart b/lib/presentation/widgets/buttons/animated_dialog_button.dart index 798edfa..70deea6 100644 --- a/lib/presentation/widgets/buttons/animated_dialog_button.dart +++ b/lib/presentation/widgets/buttons/animated_dialog_button.dart @@ -1,22 +1,28 @@ import 'package:flutter/material.dart'; -import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; class AnimatedDialogButton extends StatefulWidget { /// A custom animated button widget that provides a scaling and opacity effect /// when pressed. /// - [onPressed]: Callback function that is triggered when the button is pressed. - /// - [child]: The child widget to be displayed inside the button, typically a text or icon. + /// - [buttonText]: The text to be displayed on the button. + /// - [buttonType]: The type of the button, which determines its styling. + /// - [buttonConstraints]: Optional constraints to control the button's size. const AnimatedDialogButton({ super.key, + required this.buttonText, required this.onPressed, - required this.child, + this.buttonConstraints, + this.buttonType = ButtonType.primary, }); - /// Callback function that is triggered when the button is pressed. + final String buttonText; + final VoidCallback onPressed; - /// The child widget to be displayed inside the button, typically a text or icon. - final Widget child; + final BoxConstraints? buttonConstraints; + + final ButtonType buttonType; @override State createState() => _AnimatedDialogButtonState(); @@ -27,6 +33,29 @@ class _AnimatedDialogButtonState extends State { @override Widget build(BuildContext context) { + final textStyling = TextStyle( + color: widget.buttonType == ButtonType.primary + ? Colors.black + : Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ); + + final buttonDecoration = widget.buttonType == ButtonType.primary + // Primary + ? BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ) + : widget.buttonType == ButtonType.secondary + // Secondary + ? BoxDecoration( + border: BoxBorder.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ) + // Tertiary + : const BoxDecoration(); + return GestureDetector( onTapDown: (_) => setState(() => _isPressed = true), onTapUp: (_) => setState(() => _isPressed = false), @@ -38,10 +67,18 @@ class _AnimatedDialogButtonState extends State { child: AnimatedOpacity( opacity: _isPressed ? 0.6 : 1.0, duration: const Duration(milliseconds: 100), - child: Container( - decoration: CustomTheme.standardBoxDecoration, - padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 6), - child: widget.child, + child: Center( + child: Container( + constraints: widget.buttonConstraints, + decoration: buttonDecoration, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + margin: const EdgeInsets.symmetric(vertical: 8), + child: Text( + widget.buttonText, + style: textStyling, + textAlign: TextAlign.center, + ), + ), ), ), ), diff --git a/lib/presentation/widgets/custom_alert_dialog.dart b/lib/presentation/widgets/dialog/custom_alert_dialog.dart similarity index 74% rename from lib/presentation/widgets/custom_alert_dialog.dart rename to lib/presentation/widgets/dialog/custom_alert_dialog.dart index bf98f2c..606fc49 100644 --- a/lib/presentation/widgets/custom_alert_dialog.dart +++ b/lib/presentation/widgets/dialog/custom_alert_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart'; class CustomAlertDialog extends StatelessWidget { /// A custom alert dialog widget that provides a os unspecific AlertDialog, @@ -16,20 +17,23 @@ class CustomAlertDialog extends StatelessWidget { }); final String title; - final String content; - final List actions; + final Widget content; + final List actions; @override Widget build(BuildContext context) { return AlertDialog( - title: Text(title, style: const TextStyle(color: CustomTheme.textColor)), - content: Text( - content, - style: const TextStyle(color: CustomTheme.textColor), + title: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: CustomTheme.textColor, + ), ), + content: content, actions: actions, backgroundColor: CustomTheme.boxColor, - actionsAlignment: MainAxisAlignment.spaceAround, + actionsAlignment: MainAxisAlignment.center, shape: RoundedRectangleBorder( borderRadius: CustomTheme.standardBorderRadiusAll, side: const BorderSide(color: CustomTheme.boxBorderColor), diff --git a/lib/presentation/widgets/dialog/custom_dialog_action.dart b/lib/presentation/widgets/dialog/custom_dialog_action.dart new file mode 100644 index 0000000..aec0dfa --- /dev/null +++ b/lib/presentation/widgets/dialog/custom_dialog_action.dart @@ -0,0 +1,32 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tallee/core/enums.dart'; +import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; + +class CustomDialogAction extends StatelessWidget { + /// A custom dialog action widget that represents a button in a dialog. + /// - [text]: The text to be displayed on the button. + /// - [buttonType]: The type of the button, which determines its styling. + /// - [onPressed]: Callback function that is triggered when the button is pressed. + const CustomDialogAction({ + super.key, + required this.onPressed, + required this.text, + this.buttonType = ButtonType.primary, + }); + + final String text; + + final ButtonType buttonType; + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return AnimatedDialogButton( + onPressed: onPressed, + buttonText: text, + buttonType: buttonType, + buttonConstraints: const BoxConstraints(minWidth: 300), + ); + } +} diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index 6d8769d..0fc8ea0 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:tallee/core/common.dart'; import 'package:tallee/core/constants.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/data/db/database.dart'; @@ -62,7 +63,7 @@ class _PlayerSelectionState extends State { /// Skeleton data used while loading players. late final List skeletonData = List.filled( 7, - Player(name: 'Player 0', description: ''), + Player(name: 'Player 0'), ); @override @@ -140,6 +141,7 @@ class _PlayerSelectionState extends State { padding: const EdgeInsets.only(right: 8.0), child: TextIconTile( text: player.name, + suffixText: getNameCountText(player), onIconTap: () { setState(() { // Removes the player from the selection and notifies the parent. @@ -193,6 +195,7 @@ class _PlayerSelectionState extends State { itemBuilder: (BuildContext context, int index) { return TextIconListTile( text: suggestedPlayers[index].name, + suffixText: getNameCountText(suggestedPlayers[index]), onPressed: () { setState(() { // If the player is not already selected @@ -282,7 +285,8 @@ class _PlayerSelectionState extends State { final loc = AppLocalizations.of(context); final playerName = _searchBarController.text.trim(); - final createdPlayer = Player(name: playerName, description: ''); + int nameCount = _calculateNameCount(playerName); + final createdPlayer = Player(name: playerName, nameCount: nameCount); final success = await db.playerDao.addPlayer(player: createdPlayer); if (!context.mounted) return; @@ -295,6 +299,22 @@ class _PlayerSelectionState extends State { } } + int _calculateNameCount(String playerName) { + final playersWithSameName = + allPlayers.where((player) => player.name == playerName).toList() + ..sort((a, b) => a.nameCount.compareTo(b.nameCount)); + + if (playersWithSameName.isEmpty) { + return 0; + } else if (playersWithSameName.length == 1) { + // Initialize nameCount + playersWithSameName[0].nameCount = 1; + } + + // Return following count + return playersWithSameName.length + 1; + } + /// Updates the state after successfully adding a new player. void _handleSuccessfulPlayerCreation(Player player) { selectedPlayers.insert(0, player); diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index b62f3ce..f4ace65 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/data/models/group.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -81,7 +82,11 @@ class _GroupTileState extends State { for (var member in [ ...widget.group.members, ]..sort((a, b) => a.name.compareTo(b.name))) - TextIconTile(text: member.name, iconEnabled: false), + TextIconTile( + text: member.name, + suffixText: getNameCountText(member), + iconEnabled: false, + ), ], ), const SizedBox(height: 2.5), diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index 39f9cdf..f7585d6 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -44,7 +45,6 @@ class _MatchTileState extends State { Widget build(BuildContext context) { final match = widget.match; final group = match.group; - final winner = match.winner; final players = [...match.players] ..sort((a, b) => a.name.compareTo(b.name)); final loc = AppLocalizations.of(context); @@ -79,8 +79,7 @@ class _MatchTileState extends State { ], ), - const SizedBox(height: 8), - + // Group Info if (group != null) ...[ Row( children: [ @@ -95,7 +94,7 @@ class _MatchTileState extends State { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 4), ] else if (widget.compact) ...[ Row( children: [ @@ -110,10 +109,69 @@ class _MatchTileState extends State { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 6), + ] else ...[ + const SizedBox(height: 8), ], - if (winner != null) ...[ + // Game + Ruleset Badge + if (!widget.compact) + IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Game + Container( + decoration: BoxDecoration( + color: CustomTheme.primaryColor.withAlpha(230), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Text( + match.game.name, + style: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + // Ruleset + Container( + decoration: BoxDecoration( + color: CustomTheme.primaryColor.withAlpha(140), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Text( + translateRulesetToString(match.game.ruleset, context), + style: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Winner / In Progress Info + if (match.mvp.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric( vertical: 8, @@ -129,15 +187,11 @@ class _MatchTileState extends State { ), child: Row( children: [ - const Icon( - Icons.emoji_events, - size: 20, - color: Colors.amber, - ), + getMvpIcon(), const SizedBox(width: 8), Expanded( child: Text( - '${loc.winner}: ${winner.name}', + getMvpText(loc), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -189,6 +243,7 @@ class _MatchTileState extends State { const SizedBox(height: 12), ], + // Players List if (players.isNotEmpty && widget.compact == false) ...[ Text( loc.players, @@ -203,7 +258,11 @@ class _MatchTileState extends State { spacing: 6, runSpacing: 6, children: players.map((player) { - return TextIconTile(text: player.name, iconEnabled: false); + return TextIconTile( + text: player.name, + suffixText: getNameCountText(player), + iconEnabled: false, + ); }).toList(), ), ], @@ -230,4 +289,44 @@ class _MatchTileState extends State { return '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(dateTime)}'; } } + + String getMvpText(AppLocalizations loc) { + if (widget.match.mvp.isEmpty) return ''; + final ruleset = widget.match.game.ruleset; + + if (ruleset == Ruleset.singleWinner) { + return '${loc.winner}: ${widget.match.mvp.first.name}'; + } else if (ruleset == Ruleset.singleLoser) { + return '${loc.loser}: ${widget.match.mvp.first.name}'; + } else if (ruleset == Ruleset.highestScore || + ruleset == Ruleset.lowestScore) { + final mvp = widget.match.mvp; + final mvpScore = widget.match.scores[mvp.first.id]?.score ?? 0; + final mvpNames = mvp.map((player) => player.name).join(', '); + + return '${loc.winner}: $mvpNames (${getPointLabel(loc, mvpScore)})'; + } + return '${loc.winner}: n.A.'; + } + + Icon getMvpIcon() { + const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + + switch (widget.match.game.ruleset) { + case Ruleset.singleWinner: + return const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + case Ruleset.singleLoser: + return const Icon( + Icons.sentiment_dissatisfied_outlined, + size: 20, + color: Colors.blue, + ); + case Ruleset.lowestScore: + return const Icon(Icons.arrow_downward, size: 20, color: Colors.orange); + case Ruleset.highestScore: + return const Icon(Icons.arrow_upward, size: 20, color: Colors.green); + default: + return const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + } + } } diff --git a/lib/presentation/widgets/tiles/score_list_tile.dart b/lib/presentation/widgets/tiles/score_list_tile.dart new file mode 100644 index 0000000..52103fa --- /dev/null +++ b/lib/presentation/widgets/tiles/score_list_tile.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; + +class ScoreListTile extends StatelessWidget { + /// A custom list tile widget that has a text field for inputting a score. + /// - [text]: The leading text to be displayed. + /// - [controller]: The controller for the text field to input the score. + const ScoreListTile({ + super.key, + required this.text, + required this.controller, + }); + + /// The text to display next to the radio button. + final String text; + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: const BoxDecoration(color: CustomTheme.boxColor), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + text, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500), + ), + SizedBox( + width: 100, + height: 40, + child: TextField( + controller: controller, + keyboardType: TextInputType.number, + maxLength: 4, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: CustomTheme.textColor, + ), + cursorColor: CustomTheme.textColor, + decoration: InputDecoration( + hintText: loc.points, + counterText: '', + filled: true, + fillColor: CustomTheme.onBoxColor, + contentPadding: const EdgeInsets.symmetric( + horizontal: 0, + vertical: 0, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: CustomTheme.textColor.withAlpha(100), + width: 2, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: CustomTheme.primaryColor, + width: 2, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/statistics_tile.dart b/lib/presentation/widgets/tiles/statistics_tile.dart index bc2f7b6..ea9cb49 100644 --- a/lib/presentation/widgets/tiles/statistics_tile.dart +++ b/lib/presentation/widgets/tiles/statistics_tile.dart @@ -1,6 +1,9 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/data/models/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; @@ -32,7 +35,7 @@ class StatisticsTile extends StatelessWidget { final double width; /// A list of tuples containing labels and their corresponding numeric values. - final List<(String, num)> values; + final List<(Player, num)> values; /// The maximum number of items to display. final int itemCount; @@ -89,11 +92,29 @@ class StatisticsTile extends StatelessWidget { ), Padding( padding: const EdgeInsets.only(left: 4.0), - child: Text( - values[index].$1, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + child: RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: values[index].$1.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: getNameCountText(values[index].$1), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor.withAlpha( + 150, + ), + ), + ), + ], ), ), ), diff --git a/lib/presentation/widgets/tiles/text_icon_list_tile.dart b/lib/presentation/widgets/tiles/text_icon_list_tile.dart index 2b29d41..a31f2ae 100644 --- a/lib/presentation/widgets/tiles/text_icon_list_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_list_tile.dart @@ -9,6 +9,7 @@ class TextIconListTile extends StatelessWidget { const TextIconListTile({ super.key, required this.text, + this.suffixText = '', this.iconEnabled = true, this.onPressed, }); @@ -16,6 +17,9 @@ class TextIconListTile extends StatelessWidget { /// The text to display in the tile. final String text; + /// An optional suffix text to display after the main text. + final String suffixText; + /// A boolean to determine if the icon should be displayed. final bool iconEnabled; @@ -35,12 +39,27 @@ class TextIconListTile extends StatelessWidget { Flexible( child: Container( padding: const EdgeInsets.symmetric(vertical: 12.5), - child: Text( - text, + child: RichText( overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + TextSpan( + text: suffixText, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: CustomTheme.textColor.withAlpha(100), + ), + ), + ], ), ), ), diff --git a/lib/presentation/widgets/tiles/text_icon_tile.dart b/lib/presentation/widgets/tiles/text_icon_tile.dart index f98e0a7..541b6ae 100644 --- a/lib/presentation/widgets/tiles/text_icon_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_tile.dart @@ -9,6 +9,7 @@ class TextIconTile extends StatelessWidget { const TextIconTile({ super.key, required this.text, + this.suffixText = '', this.iconEnabled = true, this.onIconTap, }); @@ -16,6 +17,8 @@ class TextIconTile extends StatelessWidget { /// The text to display in the tile. final String text; + final String suffixText; + /// A boolean to determine if the icon should be displayed. final bool iconEnabled; @@ -36,10 +39,28 @@ class TextIconTile extends StatelessWidget { children: [ if (iconEnabled) const SizedBox(width: 3), Flexible( - child: Text( - text, + child: RichText( overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + TextSpan( + text: suffixText, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: CustomTheme.textColor.withAlpha(120), + ), + ), + ], + ), ), ), if (iconEnabled) ...[ diff --git a/lib/services/data_transfer_service.dart b/lib/services/data_transfer_service.dart index a0fd57b..daf4768 100644 --- a/lib/services/data_transfer_service.dart +++ b/lib/services/data_transfer_service.dart @@ -12,6 +12,7 @@ import 'package:tallee/data/models/game.dart'; import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/player.dart'; +import 'package:tallee/data/models/score_entry.dart'; import 'package:tallee/data/models/team.dart'; class DataTransferService { @@ -36,59 +37,12 @@ class DataTransferService { final games = await db.gameDao.getAllGames(); final teams = await db.teamDao.getAllTeams(); - // Construct a JSON representation of the data in normalized format final Map jsonMap = { - 'players': players.map((p) => p.toJson()).toList(), - 'games': games.map((g) => g.toJson()).toList(), - 'groups': groups - .map( - (g) => { - 'id': g.id, - 'name': g.name, - 'description': g.description, - 'createdAt': g.createdAt.toIso8601String(), - 'memberIds': (g.members).map((m) => m.id).toList(), - }, - ) - .toList(), - 'teams': teams - .map( - (t) => { - 'id': t.id, - 'name': t.name, - 'createdAt': t.createdAt.toIso8601String(), - 'memberIds': (t.members).map((m) => m.id).toList(), - }, - ) - .toList(), - 'matches': matches - .map( - (m) => { - 'id': m.id, - 'name': m.name, - 'createdAt': m.createdAt.toIso8601String(), - 'endedAt': m.endedAt?.toIso8601String(), - 'gameId': m.game.id, - 'groupId': m.group?.id, - 'playerIds': m.players.map((p) => p.id).toList(), - 'scores': m.scores.map( - (playerId, scores) => MapEntry( - playerId, - scores - .map( - (s) => { - 'roundNumber': s.roundNumber, - 'score': s.score, - 'change': s.change, - }, - ) - .toList(), - ), - ), - 'notes': m.notes, - }, - ) - .toList(), + 'players': players.map((player) => player.toJson()).toList(), + 'games': games.map((game) => game.toJson()).toList(), + 'groups': groups.map((group) => group.toJson()).toList(), + 'teams': teams.map((team) => team.toJson()).toList(), + 'matches': matches.map((match) => match.toJson()).toList(), }; return json.encode(jsonMap); @@ -105,7 +59,7 @@ class DataTransferService { ) async { try { final bytes = Uint8List.fromList(utf8.encode(jsonString)); - final path = await FilePicker.platform.saveFile( + final path = await FilePicker.saveFile( fileName: '$fileName.json', bytes: bytes, ); @@ -126,7 +80,7 @@ class DataTransferService { static Future importData(BuildContext context) async { final db = Provider.of(context, listen: false); - final path = await FilePicker.platform.pickFiles( + final path = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['json'], ); @@ -284,6 +238,15 @@ class DataTransferService { ? DateTime.parse(map['endedAt'] as String) : null; final notes = map['notes'] as String? ?? ''; + final scoresJson = map['scores'] as Map? ?? {}; + final scores = scoresJson.map( + (key, value) => MapEntry( + key, + value != null + ? ScoreEntry.fromJson(value as Map) + : null, + ), + ); // Link attributes to objects final game = gamesMap[gameId] ?? getFallbackGame(); @@ -305,6 +268,7 @@ class DataTransferService { createdAt: createdAt, endedAt: endedAt, notes: notes, + scores: scores, ); }).toList(); } diff --git a/pubspec.yaml b/pubspec.yaml index 641b37b..363ea7f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,24 +1,24 @@ name: tallee description: "Tracking App for Card Games" publish_to: 'none' -version: 0.0.20+254 +version: 0.0.23+257 environment: sdk: ^3.8.1 dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter clock: ^1.1.2 cupertino_icons: ^1.0.6 drift: ^2.27.0 drift_flutter: ^0.2.4 - file_picker: ^10.3.6 + file_picker: ^11.0.2 file_saver: ^0.3.1 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter fluttericon: ^2.0.0 - font_awesome_flutter: ^10.12.0 + font_awesome_flutter: ^11.0.0 intl: any json_schema: ^5.2.2 package_info_plus: ^9.0.0 @@ -33,7 +33,7 @@ dev_dependencies: sdk: flutter build_runner: ^2.7.0 dart_pubspec_licenses: ^3.0.14 - drift_dev: ^2.29.0 + drift_dev: ^2.27.0 flutter_lints: ^6.0.0 flutter: diff --git a/test/db_tests/aggregates/group_test.dart b/test/db_tests/aggregates/group_test.dart index 5e713c4..3d51a06 100644 --- a/test/db_tests/aggregates/group_test.dart +++ b/test/db_tests/aggregates/group_test.dart @@ -29,10 +29,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); testGroup1 = Group( name: 'Test Group', description: '', diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index dee0eb9..3305b9a 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -8,6 +8,7 @@ import 'package:tallee/data/models/game.dart'; import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/player.dart'; +import 'package:tallee/data/models/score_entry.dart'; void main() { late AppDatabase database; @@ -36,11 +37,11 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); - testPlayer5 = Player(name: 'Eve', description: ''); + 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 1', description: '', @@ -63,29 +64,24 @@ void main() { game: testGame, group: testGroup1, players: [testPlayer4, testPlayer5], - winner: testPlayer4, - notes: '', + scores: {testPlayer4.id: ScoreEntry(score: 1)}, ); testMatch2 = Match( name: 'Second Test Match', game: testGame, group: testGroup2, players: [testPlayer1, testPlayer2, testPlayer3], - winner: testPlayer2, - notes: '', ); testMatchOnlyPlayers = Match( name: 'Test Match with Players', game: testGame, players: [testPlayer1, testPlayer2, testPlayer3], - winner: testPlayer3, - notes: '', ); testMatchOnlyGroup = Match( name: 'Test Match with Group', game: testGame, group: testGroup2, - notes: '', + players: testGroup2.members, ); }); await database.playerDao.addPlayersAsList( @@ -289,8 +285,8 @@ void main() { matchId: testMatch1.id, ); - expect(fetchedMatch.winner, isNotNull); - expect(fetchedMatch.winner!.id, testPlayer4.id); + expect(fetchedMatch.mvp, isNotNull); + expect(fetchedMatch.mvp.first.id, testPlayer4.id); }); test('Setting a winner works correctly', () async { @@ -304,8 +300,8 @@ void main() { final fetchedMatch = await database.matchDao.getMatchById( matchId: testMatch1.id, ); - expect(fetchedMatch.winner, isNotNull); - expect(fetchedMatch.winner!.id, testPlayer5.id); + expect(fetchedMatch.mvp, isNotNull); + expect(fetchedMatch.mvp.first.id, testPlayer5.id); }); test( diff --git a/test/db_tests/aggregates/team_test.dart b/test/db_tests/aggregates/team_test.dart index 327bc8f..39c5be5 100644 --- a/test/db_tests/aggregates/team_test.dart +++ b/test/db_tests/aggregates/team_test.dart @@ -33,10 +33,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]); testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]); testTeam3 = Team(name: 'Team Gamma', members: [testPlayer1, testPlayer3]); @@ -343,8 +343,16 @@ void main() { // Verifies that teams with overlapping members are independent. test('Teams with overlapping members are independent', () async { // Create two matches since player_match has primary key {playerId, matchId} - final match1 = Match(name: 'Match 1', game: testGame1, notes: ''); - final match2 = Match(name: 'Match 2', game: testGame2, notes: ''); + final match1 = Match( + name: 'Match 1', + game: testGame1, + players: [testPlayer1, testPlayer2], + ); + final match2 = Match( + name: 'Match 2', + game: testGame2, + players: [testPlayer1, testPlayer2], + ); await database.matchDao.addMatch(match: match1); await database.matchDao.addMatch(match: match2); diff --git a/test/db_tests/entities/player_test.dart b/test/db_tests/entities/player_test.dart index 3042b33..1aab348 100644 --- a/test/db_tests/entities/player_test.dart +++ b/test/db_tests/entities/player_test.dart @@ -1,5 +1,5 @@ import 'package:clock/clock.dart'; -import 'package:drift/drift.dart' hide isNull; +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tallee/data/db/database.dart'; @@ -24,10 +24,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Test Player', description: ''); - testPlayer2 = Player(name: 'Second Player', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Test Player'); + testPlayer2 = Player(name: 'Second Player'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); }); }); tearDown(() async { @@ -348,7 +348,7 @@ void main() { // Verifies that a player with empty string name is stored correctly. test('Player with empty string name is stored correctly', () async { - final emptyNamePlayer = Player(name: '', description: ''); + final emptyNamePlayer = Player(name: ''); await database.playerDao.addPlayer(player: emptyNamePlayer); @@ -361,7 +361,7 @@ void main() { // Verifies that a player with very long name is stored correctly. test('Player with very long name is stored correctly', () async { final longName = 'A' * 1000; - final longNamePlayer = Player(name: longName, description: ''); + final longNamePlayer = Player(name: longName); await database.playerDao.addPlayer(player: longNamePlayer); @@ -381,5 +381,160 @@ void main() { ); expect(playerExists, true); }); + + group('Name Count Tests', () { + test('Single player gets initialized wih name count 0', () async { + await database.playerDao.addPlayer(player: testPlayer1); + + final player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 0); + }); + + test('Multiple players get initialized wih name count 0', () async { + await database.playerDao.addPlayersAsList( + players: [testPlayer1, testPlayer2], + ); + + final players = await database.playerDao.getAllPlayers(); + + expect(players.length, 2); + for (Player p in players) { + expect(p.nameCount, 0); + } + }); + + test( + 'Seperatly added players nameCount gets increased correctly', + () async { + await database.playerDao.addPlayer(player: testPlayer1); + + final player1 = Player(name: testPlayer1.name, description: ''); + await database.playerDao.addPlayer(player: player1); + + var players = await database.playerDao.getAllPlayers(); + + expect(players.length, 2); + players.sort((a, b) => a.nameCount.compareTo(b.nameCount)); + + for (int i = 0; i < players.length - 1; i++) { + expect(players[i].nameCount, i + 1); + } + }, + ); + + test( + 'Together added players nameCount gets increased correctly', + () async { + final player1 = Player(name: testPlayer1.name, description: ''); + final player2 = Player(name: testPlayer1.name, description: ''); + final player3 = Player(name: testPlayer1.name, description: ''); + + // addPlayersAsList() with multiple players and with one player + await database.playerDao.addPlayersAsList(players: [testPlayer1]); + await database.playerDao.addPlayersAsList( + players: [player1, player2, player3], + ); + + var players = await database.playerDao.getAllPlayers(); + + expect(players.length, 4); + players.sort((a, b) => a.nameCount.compareTo(b.nameCount)); + + for (int i = 0; i < players.length - 1; i++) { + expect(players[i].nameCount, i + 1); + } + }, + ); + + test('getNameCount works correctly', () async { + final player2 = Player(name: testPlayer1.name); + final player3 = Player(name: testPlayer1.name); + + await database.playerDao.addPlayersAsList( + players: [testPlayer1, player2, player3], + ); + + final nameCount = await database.playerDao.getNameCount( + name: testPlayer1.name, + ); + + expect(nameCount, 3); + }); + + test('updateNameCount works correctly', () async { + await database.playerDao.addPlayer(player: testPlayer1); + + final success = await database.playerDao.updateNameCount( + playerId: testPlayer1.id, + nameCount: 2, + ); + expect(success, true); + + final player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 2); + }); + + test('getPlayerWithHighestNameCount works correctly', () async { + final player2 = Player(name: testPlayer1.name, description: ''); + final player3 = Player(name: testPlayer1.name, description: ''); + + await database.playerDao.addPlayersAsList( + players: [testPlayer1, player2, player3], + ); + + final player = await database.playerDao.getPlayerWithHighestNameCount( + name: testPlayer1.name, + ); + + expect(player, isNotNull); + expect(player!.nameCount, 3); + }); + + test('getPlayerWithHighestNameCount with non existing player', () async { + final player = await database.playerDao.getPlayerWithHighestNameCount( + name: 'non-existing-name', + ); + expect(player, isNull); + }); + + test('calculateNameCount works correctly', () async { + // Case 1: No existing players with the name + var count = await database.playerDao.calculateNameCount( + name: testPlayer1.name, + ); + expect(count, 0); + + // Case 2: One existing player with the name. Should update that + // player's nameCount to 1 and return 2 for the new player + await database.playerDao.addPlayer(player: testPlayer1); + + count = await database.playerDao.calculateNameCount( + name: testPlayer1.name, + ); + expect(count, 2); + + // Case 3: Multiple existing players with the name. + final player2 = Player(name: testPlayer1.name, nameCount: count); + await database.playerDao.addPlayer(player: player2); + + count = await database.playerDao.calculateNameCount( + name: testPlayer1.name, + ); + expect(count, 3); + }); + + test('getPlayerWithHighestNameCount with non existing player', () async { + await database.playerDao.addPlayer(player: testPlayer1); + await database.playerDao.initializeNameCount(name: testPlayer1.name); + final player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 1); + }); + }); }); } diff --git a/test/db_tests/relationships/player_group_test.dart b/test/db_tests/relationships/player_group_test.dart index 7004e17..f687b1c 100644 --- a/test/db_tests/relationships/player_group_test.dart +++ b/test/db_tests/relationships/player_group_test.dart @@ -26,10 +26,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); testGroup = Group( name: 'Test Group', description: '', diff --git a/test/db_tests/relationships/player_match_test.dart b/test/db_tests/relationships/player_match_test.dart index 3db48de..92601f0 100644 --- a/test/db_tests/relationships/player_match_test.dart +++ b/test/db_tests/relationships/player_match_test.dart @@ -37,12 +37,12 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); - testPlayer5 = Player(name: 'Eve', description: ''); - testPlayer6 = Player(name: 'Frank', description: ''); + 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', description: '', @@ -58,14 +58,13 @@ void main() { testMatchOnlyGroup = Match( name: 'Test Match with Group', game: testGame, + players: testGroup.members, group: testGroup, - notes: '', ); testMatchOnlyPlayers = Match( name: 'Test Match with Players', game: testGame, players: [testPlayer4, testPlayer5, testPlayer6], - notes: '', ); testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]); testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]); @@ -96,7 +95,7 @@ void main() { matchId: testMatchOnlyGroup.id, ); - expect(matchHasPlayers, false); + expect(matchHasPlayers, true); await database.playerMatchDao.addPlayerToMatch( matchId: testMatchOnlyGroup.id, @@ -397,7 +396,6 @@ void main() { matchId: testMatchOnlyGroup.id, teamId: testTeam1.id, ); - expect(playersInTeam.length, 2); final playerIds = playersInTeam.map((p) => p.id).toSet(); expect(playerIds.contains(testPlayer1.id), true); @@ -426,18 +424,16 @@ void main() { playerId: testPlayer1.id, ); - // Try to add the same player again with different score await database.playerMatchDao.addPlayerToMatch( matchId: testMatchOnlyGroup.id, playerId: testPlayer1.id, ); - // Verify player count is still 1 final players = await database.playerMatchDao.getPlayersOfMatch( matchId: testMatchOnlyGroup.id, ); - expect(players?.length, 1); + expect(players?.length, 3); }); test( @@ -546,6 +542,7 @@ void main() { matchId: testMatchOnlyGroup.id, teamId: testTeam1.id, ); + expect(playersInTeam1.length, 2); final team1Ids = playersInTeam1.map((p) => p.id).toSet(); expect(team1Ids.contains(testPlayer1.id), true); @@ -568,13 +565,11 @@ void main() { name: 'Match 1', game: testGame, players: playersList, - notes: '', ); final match2 = Match( name: 'Match 2', game: testGame, players: playersList, - notes: '', ); await Future.wait([ diff --git a/test/db_tests/values/score_test.dart b/test/db_tests/values/score_test.dart index fc87cc4..d550995 100644 --- a/test/db_tests/values/score_test.dart +++ b/test/db_tests/values/score_test.dart @@ -30,9 +30,9 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); testGame = Game( name: 'Test Game', ruleset: Ruleset.singleWinner, @@ -44,13 +44,11 @@ void main() { name: 'Test Match 1', game: testGame, players: [testPlayer1, testPlayer2], - notes: '', ); testMatch2 = Match( name: 'Test Match 2', game: testGame, players: [testPlayer2, testPlayer3], - notes: '', ); }); @@ -231,8 +229,8 @@ void main() { ); expect(scores.length, 2); - expect(scores[testPlayer1.id]!.length, 2); - expect(scores[testPlayer2.id]!.length, 1); + expect(scores[testPlayer1.id]!, isNotNull); + expect(scores[testPlayer2.id]!, isNotNull); }); test('getAllMatchScores() with no scores saved', () async { diff --git a/test/services/data_transfer_service_test.dart b/test/services/data_transfer_service_test.dart index 575e52f..e863629 100644 --- a/test/services/data_transfer_service_test.dart +++ b/test/services/data_transfer_service_test.dart @@ -64,14 +64,8 @@ void main() { players: [testPlayer1, testPlayer2], notes: 'Test notes', scores: { - testPlayer1.id: [ - ScoreEntry(roundNumber: 1, score: 10, change: 10), - ScoreEntry(roundNumber: 2, score: 20, change: 10), - ], - testPlayer2.id: [ - ScoreEntry(roundNumber: 1, score: 15, change: 15), - ScoreEntry(roundNumber: 2, score: 25, change: 10), - ], + testPlayer1.id: ScoreEntry(roundNumber: 1, score: 10, change: 10), + testPlayer2.id: ScoreEntry(roundNumber: 1, score: 15, change: 15), }, ); }); @@ -302,46 +296,25 @@ void main() { final scoresJson = matchData['scores'] as Map; expect(scoresJson, isA>()); - final scores = scoresJson.map( - (playerId, scoreList) => MapEntry( - playerId, - (scoreList as List) - .map((s) => ScoreEntry.fromJson(s as Map)) - .toList(), - ), - ); + // Verify scores are properly structured (single score per player, not list) + expect(scoresJson[testPlayer1.id], isNotNull); + expect(scoresJson[testPlayer2.id], isNotNull); - expect(scores, isA>>()); + // Parse player 1 score + final player1ScoreJson = + scoresJson[testPlayer1.id] as Map; + final player1Score = ScoreEntry.fromJson(player1ScoreJson); + expect(player1Score.roundNumber, 1); + expect(player1Score.score, 10); + expect(player1Score.change, 10); - /* Player 1 scores */ - // General structure - expect(scores[testPlayer1.id], isNotNull); - expect(scores[testPlayer1.id]!.length, 2); - - // Round 1 - expect(scores[testPlayer1.id]![0].roundNumber, 1); - expect(scores[testPlayer1.id]![0].score, 10); - expect(scores[testPlayer1.id]![0].change, 10); - - // Round 2 - expect(scores[testPlayer1.id]![1].roundNumber, 2); - expect(scores[testPlayer1.id]![1].score, 20); - expect(scores[testPlayer1.id]![1].change, 10); - - /* Player 2 scores */ - // General structure - expect(scores[testPlayer2.id], isNotNull); - expect(scores[testPlayer2.id]!.length, 2); - - // Round 1 - expect(scores[testPlayer2.id]![0].roundNumber, 1); - expect(scores[testPlayer2.id]![0].score, 15); - expect(scores[testPlayer2.id]![0].change, 15); - - // Round 2 - expect(scores[testPlayer2.id]![1].roundNumber, 2); - expect(scores[testPlayer2.id]![1].score, 25); - expect(scores[testPlayer2.id]![1].change, 10); + // Parse player 2 score + final player2ScoreJson = + scoresJson[testPlayer2.id] as Map; + final player2Score = ScoreEntry.fromJson(player2ScoreJson); + expect(player2Score.roundNumber, 1); + expect(player2Score.score, 15); + expect(player2Score.change, 15); }); testWidgets('Match without group is handled correctly', (tester) async { @@ -904,14 +877,8 @@ void main() { 'playerIds': [testPlayer1.id, testPlayer2.id], 'notes': testMatch.notes, 'scores': { - testPlayer1.id: [ - {'roundNumber': 1, 'score': 10, 'change': 10}, - {'roundNumber': 2, 'score': 20, 'change': 10}, - ], - testPlayer2.id: [ - {'roundNumber': 1, 'score': 15, 'change': 15}, - {'roundNumber': 2, 'score': 25, 'change': 10}, - ], + testPlayer1.id: {'roundNumber': 1, 'score': 10, 'change': 10}, + testPlayer2.id: {'roundNumber': 1, 'score': 15, 'change': 15}, }, 'createdAt': testMatch.createdAt.toIso8601String(), 'endedAt': null,