diff --git a/assets/schema.json b/assets/schema.json index 7f6aebd..8ec904a 100644 --- a/assets/schema.json +++ b/assets/schema.json @@ -166,6 +166,9 @@ "notes": { "type": "string" }, + "isTeamMatch": { + "type": "boolean" + }, "teams": { "type": ["array", "null"] } @@ -177,7 +180,8 @@ "createdAt", "gameId", "playerIds", - "notes" + "notes", + "isTeamMatch" ] } } diff --git a/lib/core/common.dart b/lib/core/common.dart index 312e3fa..764094e 100644 --- a/lib/core/common.dart +++ b/lib/core/common.dart @@ -24,47 +24,62 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) { } } -/// Translates a [GameColor] enum value to its corresponding localized string. -String translateGameColorToString(GameColor color, BuildContext context) { +// Returns a AppColor enum value based on the provided team [index]. +AppColor getTeamColor(int index) { + final colors = [ + AppColor.red, + AppColor.blue, + AppColor.green, + AppColor.yellow, + AppColor.purple, + AppColor.orange, + AppColor.pink, + AppColor.teal, + ]; + return colors[index % colors.length]; +} + +/// Translates a [AppColor] enum value to its corresponding localized string. +String translateGameColorToString(AppColor color, BuildContext context) { final loc = AppLocalizations.of(context); switch (color) { - case GameColor.red: + case AppColor.red: return loc.color_red; - case GameColor.blue: + case AppColor.blue: return loc.color_blue; - case GameColor.green: + case AppColor.green: return loc.color_green; - case GameColor.yellow: + case AppColor.yellow: return loc.color_yellow; - case GameColor.purple: + case AppColor.purple: return loc.color_purple; - case GameColor.orange: + case AppColor.orange: return loc.color_orange; - case GameColor.pink: + case AppColor.pink: return loc.color_pink; - case GameColor.teal: + case AppColor.teal: return loc.color_teal; } } -/// Returns the [Color] object corresponding to a [GameColor] enum value. -Color getColorFromGameColor(GameColor color) { +/// Returns the [Color] object corresponding to a [AppColor] enum value. +Color getColorFromGameColor(AppColor color) { switch (color) { - case GameColor.red: + case AppColor.red: return Colors.red; - case GameColor.blue: + case AppColor.blue: return Colors.blue; - case GameColor.green: + case AppColor.green: return Colors.green; - case GameColor.yellow: + case AppColor.yellow: return const Color(0xFFF7CA28); - case GameColor.purple: + case AppColor.purple: return Colors.purple; - case GameColor.orange: + case AppColor.orange: return const Color(0xFFef681f); - case GameColor.pink: + case AppColor.pink: return Colors.pink; - case GameColor.teal: + case AppColor.teal: return Colors.teal; } } @@ -77,11 +92,10 @@ IconData getRulesetIcon(Ruleset ruleset) { case Ruleset.lowestScore: return Icons.arrow_downward; case Ruleset.singleWinner: + case Ruleset.multipleWinners: return Icons.emoji_events; case Ruleset.singleLoser: return Icons.sentiment_dissatisfied; - case Ruleset.multipleWinners: - return Icons.group; case Ruleset.placement: return RpgAwesome.podium; } @@ -113,6 +127,7 @@ String getExtraPlayerCount(Match match) { return ' + ${count.toString()}'; } +/// Returns the player name count if greater 0 in the format " #2", otherwise an empty string String getNameCountText(Player player) { if (player.nameCount >= 1) { return ' #${player.nameCount}'; @@ -120,6 +135,7 @@ String getNameCountText(Player player) { return ''; } +/// Returns the correct singular or plural form of "point(s)" based on the [points] value. String getPointLabel(AppLocalizations loc, int points) { if (points == 1) { return '$points ${loc.point}'; diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index bb6d4b4..585e951 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -65,7 +65,11 @@ class CustomTheme { static BoxDecoration highlightedBoxDecoration = BoxDecoration( color: boxColor, - border: Border.all(color: textColor, width: 2), + border: Border.all( + color: textColor, + width: 2, + strokeAlign: BorderSide.strokeAlignCenter, + ), borderRadius: standardBorderRadiusAll, ); diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 99141e4..f1ef571 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -42,5 +42,5 @@ enum Ruleset { singleLoser, } -/// Different colors for highlighting games -enum GameColor { red, orange, yellow, green, teal, blue, purple, pink } +/// Different colors for highlighting content +enum AppColor { red, orange, yellow, green, teal, blue, purple, pink } diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index a4c2300..1adfc9e 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -92,7 +92,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { name: row.name, ruleset: Ruleset.values.firstWhere((e) => e.name == row.ruleset), description: row.description, - color: GameColor.values.firstWhere((e) => e.name == row.color), + color: AppColor.values.firstWhere((e) => e.name == row.color), icon: row.icon, createdAt: row.createdAt, ), @@ -109,7 +109,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { name: result.name, ruleset: Ruleset.values.firstWhere((e) => e.name == result.ruleset), description: result.description, - color: GameColor.values.firstWhere((e) => e.name == result.color), + color: AppColor.values.firstWhere((e) => e.name == result.color), icon: result.icon, createdAt: result.createdAt, ); @@ -156,7 +156,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { /// Updates the color of the game with the given [gameId]. Future updateGameColor({ required String gameId, - required GameColor color, + required AppColor color, }) async { final rowsAffected = await (update(gameTable)..where((g) => g.id.equals(gameId))).write( diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 88cca35..39e0990 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -30,6 +30,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { gameId: match.game.id, groupId: Value(match.group?.id), name: match.name, + isTeamMatch: Value(match.isTeamMatch), notes: match.notes, createdAt: match.createdAt, endedAt: Value(match.endedAt), @@ -142,6 +143,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { gameId: match.game.id, groupId: Value(match.group?.id), name: match.name, + isTeamMatch: Value(match.isTeamMatch), notes: match.notes, createdAt: match.createdAt, endedAt: Value(match.endedAt), @@ -300,6 +302,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { group: group, players: players, teams: teams.isEmpty ? null : teams, + isTeamMatch: row.isTeamMatch, notes: row.notes, createdAt: row.createdAt, endedAt: row.endedAt, @@ -334,6 +337,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { group: group, players: players, teams: teams.isEmpty ? null : teams, + isTeamMatch: result.isTeamMatch, notes: result.notes, createdAt: result.createdAt, endedAt: result.endedAt, @@ -373,6 +377,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { group: group, players: players, teams: teams.isEmpty ? null : teams, + isTeamMatch: row.isTeamMatch, notes: row.notes, createdAt: row.createdAt, endedAt: row.endedAt, @@ -401,7 +406,8 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { teamIds.map((teamId) => db.teamDao.getTeamById(teamId: teamId)), ); - return teams; + return teams + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } /* Update */ diff --git a/lib/data/dao/player_match_dao.dart b/lib/data/dao/player_match_dao.dart index d119468..5ddc72e 100644 --- a/lib/data/dao/player_match_dao.dart +++ b/lib/data/dao/player_match_dao.dart @@ -74,7 +74,8 @@ class PlayerMatchDao extends DatabaseAccessor (row) => db.playerDao.getPlayerById(playerId: row.playerId), ); final players = await Future.wait(futures); - return players; + return players + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } /// Retrieves a list of [Player]s associated with a specific team in a match. diff --git a/lib/data/dao/score_entry_dao.dart b/lib/data/dao/score_entry_dao.dart index 830135d..9e41524 100644 --- a/lib/data/dao/score_entry_dao.dart +++ b/lib/data/dao/score_entry_dao.dart @@ -16,12 +16,12 @@ class ScoreEntryDao extends DatabaseAccessor /* Create */ /// Adds a score entry to the database. - Future addScore({ + Future addScore({ required String playerId, required String matchId, required ScoreEntry entry, }) async { - await into(scoreEntryTable).insert( + final rowsAffected = await into(scoreEntryTable).insert( ScoreEntryTableCompanion.insert( playerId: playerId, matchId: matchId, @@ -31,6 +31,8 @@ class ScoreEntryDao extends DatabaseAccessor ), mode: InsertMode.insertOrReplace, ); + + return rowsAffected > 0; } Future addScoresAsList({ diff --git a/lib/data/dao/team_dao.dart b/lib/data/dao/team_dao.dart index cba68fb..213d24e 100644 --- a/lib/data/dao/team_dao.dart +++ b/lib/data/dao/team_dao.dart @@ -1,8 +1,10 @@ import 'package:drift/drift.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/db/tables/player_match_table.dart'; import 'package:tallee/data/db/tables/team_table.dart'; import 'package:tallee/data/models/player.dart'; +import 'package:tallee/data/models/score_entry.dart'; import 'package:tallee/data/models/team.dart'; part 'team_dao.g.dart'; @@ -22,6 +24,8 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { id: team.id, name: team.name, createdAt: team.createdAt, + color: Value(team.color.name), + score: Value(team.score), ), mode: InsertMode.insertOrReplace, ); @@ -56,6 +60,8 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { id: team.id, name: team.name, createdAt: team.createdAt, + color: Value(team.color.name), + score: Value(team.score), ), ) .toList(), @@ -110,12 +116,32 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { id: row.id, name: row.name, createdAt: row.createdAt, + color: AppColor.values.byName(row.color), + score: row.score, members: members, ); }), ); } + Future> getTeamsByMatchId({required String matchId}) async { + final playerMatchQuery = select(db.playerMatchTable) + ..where((pm) => pm.matchId.equals(matchId)); + final playerMatches = await playerMatchQuery.get(); + + if (playerMatches.isEmpty) return []; + + final teamIds = playerMatches + .map((pm) => pm.teamId) + .whereType() + .toSet(); + + final teams = await Future.wait( + teamIds.map((id) => getTeamById(teamId: id)), + ); + return teams; + } + /// Retrieves a [Team] by its [teamId], including its members. Future getTeamById({required String teamId}) async { final query = select(teamTable)..where((t) => t.id.equals(teamId)); @@ -125,6 +151,8 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { id: result.id, name: result.name, createdAt: result.createdAt, + color: AppColor.values.byName(result.color), + score: result.score, members: members, ); } @@ -145,7 +173,8 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { final players = await Future.wait( playerIds.map((id) => db.playerDao.getPlayerById(playerId: id)), ); - return players; + return players + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } /* Update */ @@ -162,6 +191,81 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { return rowsAffected > 0; } + /// Updates the color of the team with the given [teamId]. + Future updateTeamColor({ + required String teamId, + required AppColor color, + }) async { + final rowsAffected = + await (update(teamTable)..where((t) => t.id.equals(teamId))).write( + TeamTableCompanion(color: Value(color.name)), + ); + return rowsAffected > 0; + } + + /// Updates the score of the team with the given [teamId]. + /// Updates the member scores correspondingly + Future updateTeamScore({ + required String teamId, + required String matchId, + required int score, + }) async { + await (update(teamTable)..where((t) => t.id.equals(teamId))).write( + const TeamTableCompanion(score: Value(null)), + ); + await _deleteAllScoresForMembersOfTeam(teamId: teamId, matchId: matchId); + + final rowsAffected = + await (update(teamTable)..where((t) => t.id.equals(teamId))).write( + TeamTableCompanion(score: Value(score)), + ); + + final members = await _getTeamMembers(teamId: teamId); + for (final member in members) { + await db.scoreEntryDao.addScore( + playerId: member.id, + matchId: matchId, + entry: ScoreEntry(score: score), + ); + } + + return rowsAffected > 0; + } + + Future removeScoreForTeam({ + required String teamId, + required String matchId, + }) async { + await (update(teamTable)..where((t) => t.id.equals(teamId))).write( + const TeamTableCompanion(score: Value(null)), + ); + await _deleteAllScoresForMembersOfTeam(teamId: teamId, matchId: matchId); + return true; + } + + /// Removes the scores for all teams in the match with the given [matchId] by setting their scores to null. + Future removeAllTeamScores({required String matchId}) async { + // collect all teamIds for the given matchId from playerMatchTable + final teamIds = + await (selectOnly(playerMatchTable) + ..addColumns([playerMatchTable.teamId]) + ..where(playerMatchTable.matchId.equals(matchId))) + .map((row) => row.read(playerMatchTable.teamId)) + .get(); + + // filter null or duplicates + final filteredTeamIds = teamIds.whereType().toSet().toList(); + + var rowsAffected = 0; + if (filteredTeamIds.isNotEmpty) { + rowsAffected = + await (update(teamTable)..where((t) => t.id.isIn(filteredTeamIds))) + .write(const TeamTableCompanion(score: Value(null))); + } + await db.scoreEntryDao.deleteAllScoresForMatch(matchId: matchId); + return rowsAffected > 0; + } + /* Delete */ /// Deletes all teams from the database. @@ -179,4 +283,92 @@ class TeamDao extends DatabaseAccessor with _$TeamDaoMixin { final rowsAffected = await query.go(); return rowsAffected > 0; } + + /* Score handling */ + + /// Sets the team with the given [teamId] as the winner of the match with the given [matchId] by assigning a score of 1. + /// Returns `true` if the score was updated successfully, `false` otherwise. + Future setWinnerTeam({ + required String teamId, + required String matchId, + }) async { + return await updateTeamScore(teamId: teamId, matchId: matchId, score: 1); + } + + /// Sets multiple teams as winners of the match with the given [matchId] by assigning a score of 1 to each team. + /// Returns `true` if all scores were updated successfully, `false` otherwise. + Future setWinnerTeams({ + required List winners, + required String matchId, + }) async { + // Reset all team scores . + await removeAllTeamScores(matchId: matchId); + // Reset all score entries + for (final team in winners) { + await _deleteAllScoresForMembersOfTeam(teamId: team.id, matchId: matchId); + } + + for (final team in winners) { + await updateTeamScore(teamId: team.id, matchId: matchId, score: 1); + } + return true; + } + + /// Removes the winner status from all Teams with the given [matchId] by setting its score to null. + /// Returns `true` if the score was updated successfully, `false` otherwise. + Future removeWinnerTeam({required String matchId}) async { + return await removeAllTeamScores(matchId: matchId); + } + + /// Sets the team with the given [teamId] as the loser of the match with the given [matchId] by assigning a score of 0. + /// Returns `true` if the score was updated successfully, `false` otherwise. + Future setLoserTeam({ + required String teamId, + required String matchId, + }) async { + return await updateTeamScore(teamId: teamId, matchId: matchId, score: 0); + } + + /// Removes the loser from the match with the given [matchId] by setting its score to null. + /// Returns `true` if the score was updated successfully, `false` otherwise. + Future removeLoserTeam({required String matchId}) async { + return await removeAllTeamScores(matchId: matchId); + } + + /// Sets the placements for the teams in the match with the given [matchId] by assigning scores based on their order in the [teams] list. + /// Returns `true` if all scores were updated successfully, `false` otherwise. + Future setTeamPlacements({ + required String matchId, + required List teams, + }) async { + List success = List.generate(teams.length, (index) => null); + for (int i = 0; i < teams.length; i++) { + success[i] = await updateTeamScore( + matchId: matchId, + teamId: teams[i].id, + score: teams.length - i, + ); + } + return success.every((result) => result == true); + } + + /// Helper method to delete all scores for members of a team in a specific match. + Future _deleteAllScoresForMembersOfTeam({ + required String teamId, + required String matchId, + }) async { + final playerMatchQuery = select(db.playerMatchTable) + ..where((pm) => pm.teamId.equals(teamId) & pm.matchId.equals(matchId)); + final playerMatches = await playerMatchQuery.get(); + + if (playerMatches.isEmpty) return false; + + for (final pm in playerMatches) { + await db.scoreEntryDao.deleteAllScoresForPlayerInMatch( + playerId: pm.playerId, + matchId: matchId, + ); + } + return true; + } } diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index c8d0faa..831aa92 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -1185,6 +1185,21 @@ class $MatchTableTable extends MatchTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _isTeamMatchMeta = const VerificationMeta( + 'isTeamMatch', + ); + @override + late final GeneratedColumn isTeamMatch = GeneratedColumn( + 'is_team_match', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_team_match" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _notesMeta = const VerificationMeta('notes'); @override late final GeneratedColumn notes = GeneratedColumn( @@ -1222,6 +1237,7 @@ class $MatchTableTable extends MatchTable gameId, groupId, name, + isTeamMatch, notes, createdAt, endedAt, @@ -1265,6 +1281,15 @@ class $MatchTableTable extends MatchTable } else if (isInserting) { context.missing(_nameMeta); } + if (data.containsKey('is_team_match')) { + context.handle( + _isTeamMatchMeta, + isTeamMatch.isAcceptableOrUnknown( + data['is_team_match']!, + _isTeamMatchMeta, + ), + ); + } if (data.containsKey('notes')) { context.handle( _notesMeta, @@ -1312,6 +1337,10 @@ class $MatchTableTable extends MatchTable DriftSqlType.string, data['${effectivePrefix}name'], )!, + isTeamMatch: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_team_match'], + )!, notes: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}notes'], @@ -1338,6 +1367,7 @@ class MatchTableData extends DataClass implements Insertable { final String gameId; final String? groupId; final String name; + final bool isTeamMatch; final String notes; final DateTime createdAt; final DateTime? endedAt; @@ -1346,6 +1376,7 @@ class MatchTableData extends DataClass implements Insertable { required this.gameId, this.groupId, required this.name, + required this.isTeamMatch, required this.notes, required this.createdAt, this.endedAt, @@ -1359,6 +1390,7 @@ class MatchTableData extends DataClass implements Insertable { map['group_id'] = Variable(groupId); } map['name'] = Variable(name); + map['is_team_match'] = Variable(isTeamMatch); map['notes'] = Variable(notes); map['created_at'] = Variable(createdAt); if (!nullToAbsent || endedAt != null) { @@ -1375,6 +1407,7 @@ class MatchTableData extends DataClass implements Insertable { ? const Value.absent() : Value(groupId), name: Value(name), + isTeamMatch: Value(isTeamMatch), notes: Value(notes), createdAt: Value(createdAt), endedAt: endedAt == null && nullToAbsent @@ -1393,6 +1426,7 @@ class MatchTableData extends DataClass implements Insertable { gameId: serializer.fromJson(json['gameId']), groupId: serializer.fromJson(json['groupId']), name: serializer.fromJson(json['name']), + isTeamMatch: serializer.fromJson(json['isTeamMatch']), notes: serializer.fromJson(json['notes']), createdAt: serializer.fromJson(json['createdAt']), endedAt: serializer.fromJson(json['endedAt']), @@ -1406,6 +1440,7 @@ class MatchTableData extends DataClass implements Insertable { 'gameId': serializer.toJson(gameId), 'groupId': serializer.toJson(groupId), 'name': serializer.toJson(name), + 'isTeamMatch': serializer.toJson(isTeamMatch), 'notes': serializer.toJson(notes), 'createdAt': serializer.toJson(createdAt), 'endedAt': serializer.toJson(endedAt), @@ -1417,6 +1452,7 @@ class MatchTableData extends DataClass implements Insertable { String? gameId, Value groupId = const Value.absent(), String? name, + bool? isTeamMatch, String? notes, DateTime? createdAt, Value endedAt = const Value.absent(), @@ -1425,6 +1461,7 @@ class MatchTableData extends DataClass implements Insertable { gameId: gameId ?? this.gameId, groupId: groupId.present ? groupId.value : this.groupId, name: name ?? this.name, + isTeamMatch: isTeamMatch ?? this.isTeamMatch, notes: notes ?? this.notes, createdAt: createdAt ?? this.createdAt, endedAt: endedAt.present ? endedAt.value : this.endedAt, @@ -1435,6 +1472,9 @@ class MatchTableData extends DataClass implements Insertable { gameId: data.gameId.present ? data.gameId.value : this.gameId, groupId: data.groupId.present ? data.groupId.value : this.groupId, name: data.name.present ? data.name.value : this.name, + isTeamMatch: data.isTeamMatch.present + ? data.isTeamMatch.value + : this.isTeamMatch, notes: data.notes.present ? data.notes.value : this.notes, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, endedAt: data.endedAt.present ? data.endedAt.value : this.endedAt, @@ -1448,6 +1488,7 @@ class MatchTableData extends DataClass implements Insertable { ..write('gameId: $gameId, ') ..write('groupId: $groupId, ') ..write('name: $name, ') + ..write('isTeamMatch: $isTeamMatch, ') ..write('notes: $notes, ') ..write('createdAt: $createdAt, ') ..write('endedAt: $endedAt') @@ -1456,8 +1497,16 @@ class MatchTableData extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(id, gameId, groupId, name, notes, createdAt, endedAt); + int get hashCode => Object.hash( + id, + gameId, + groupId, + name, + isTeamMatch, + notes, + createdAt, + endedAt, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -1466,6 +1515,7 @@ class MatchTableData extends DataClass implements Insertable { other.gameId == this.gameId && other.groupId == this.groupId && other.name == this.name && + other.isTeamMatch == this.isTeamMatch && other.notes == this.notes && other.createdAt == this.createdAt && other.endedAt == this.endedAt); @@ -1476,6 +1526,7 @@ class MatchTableCompanion extends UpdateCompanion { final Value gameId; final Value groupId; final Value name; + final Value isTeamMatch; final Value notes; final Value createdAt; final Value endedAt; @@ -1485,6 +1536,7 @@ class MatchTableCompanion extends UpdateCompanion { this.gameId = const Value.absent(), this.groupId = const Value.absent(), this.name = const Value.absent(), + this.isTeamMatch = const Value.absent(), this.notes = const Value.absent(), this.createdAt = const Value.absent(), this.endedAt = const Value.absent(), @@ -1495,6 +1547,7 @@ class MatchTableCompanion extends UpdateCompanion { required String gameId, this.groupId = const Value.absent(), required String name, + this.isTeamMatch = const Value.absent(), required String notes, required DateTime createdAt, this.endedAt = const Value.absent(), @@ -1509,6 +1562,7 @@ class MatchTableCompanion extends UpdateCompanion { Expression? gameId, Expression? groupId, Expression? name, + Expression? isTeamMatch, Expression? notes, Expression? createdAt, Expression? endedAt, @@ -1519,6 +1573,7 @@ class MatchTableCompanion extends UpdateCompanion { if (gameId != null) 'game_id': gameId, if (groupId != null) 'group_id': groupId, if (name != null) 'name': name, + if (isTeamMatch != null) 'is_team_match': isTeamMatch, if (notes != null) 'notes': notes, if (createdAt != null) 'created_at': createdAt, if (endedAt != null) 'ended_at': endedAt, @@ -1531,6 +1586,7 @@ class MatchTableCompanion extends UpdateCompanion { Value? gameId, Value? groupId, Value? name, + Value? isTeamMatch, Value? notes, Value? createdAt, Value? endedAt, @@ -1541,6 +1597,7 @@ class MatchTableCompanion extends UpdateCompanion { gameId: gameId ?? this.gameId, groupId: groupId ?? this.groupId, name: name ?? this.name, + isTeamMatch: isTeamMatch ?? this.isTeamMatch, notes: notes ?? this.notes, createdAt: createdAt ?? this.createdAt, endedAt: endedAt ?? this.endedAt, @@ -1563,6 +1620,9 @@ class MatchTableCompanion extends UpdateCompanion { if (name.present) { map['name'] = Variable(name.value); } + if (isTeamMatch.present) { + map['is_team_match'] = Variable(isTeamMatch.value); + } if (notes.present) { map['notes'] = Variable(notes.value); } @@ -1585,6 +1645,7 @@ class MatchTableCompanion extends UpdateCompanion { ..write('gameId: $gameId, ') ..write('groupId: $groupId, ') ..write('name: $name, ') + ..write('isTeamMatch: $isTeamMatch, ') ..write('notes: $notes, ') ..write('createdAt: $createdAt, ') ..write('endedAt: $endedAt, ') @@ -1854,8 +1915,27 @@ class $TeamTableTable extends TeamTable type: DriftSqlType.dateTime, requiredDuringInsert: true, ); + static const VerificationMeta _colorMeta = const VerificationMeta('color'); @override - List get $columns => [id, name, createdAt]; + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('blue'), + ); + static const VerificationMeta _scoreMeta = const VerificationMeta('score'); + @override + late final GeneratedColumn score = GeneratedColumn( + 'score', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, name, createdAt, color, score]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1889,6 +1969,18 @@ class $TeamTableTable extends TeamTable } else if (isInserting) { context.missing(_createdAtMeta); } + if (data.containsKey('color')) { + context.handle( + _colorMeta, + color.isAcceptableOrUnknown(data['color']!, _colorMeta), + ); + } + if (data.containsKey('score')) { + context.handle( + _scoreMeta, + score.isAcceptableOrUnknown(data['score']!, _scoreMeta), + ); + } return context; } @@ -1910,6 +2002,14 @@ class $TeamTableTable extends TeamTable DriftSqlType.dateTime, data['${effectivePrefix}created_at'], )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + )!, + score: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}score'], + ), ); } @@ -1923,10 +2023,14 @@ class TeamTableData extends DataClass implements Insertable { final String id; final String name; final DateTime createdAt; + final String color; + final int? score; const TeamTableData({ required this.id, required this.name, required this.createdAt, + required this.color, + this.score, }); @override Map toColumns(bool nullToAbsent) { @@ -1934,6 +2038,10 @@ class TeamTableData extends DataClass implements Insertable { map['id'] = Variable(id); map['name'] = Variable(name); map['created_at'] = Variable(createdAt); + map['color'] = Variable(color); + if (!nullToAbsent || score != null) { + map['score'] = Variable(score); + } return map; } @@ -1942,6 +2050,10 @@ class TeamTableData extends DataClass implements Insertable { id: Value(id), name: Value(name), createdAt: Value(createdAt), + color: Value(color), + score: score == null && nullToAbsent + ? const Value.absent() + : Value(score), ); } @@ -1954,6 +2066,8 @@ class TeamTableData extends DataClass implements Insertable { id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), createdAt: serializer.fromJson(json['createdAt']), + color: serializer.fromJson(json['color']), + score: serializer.fromJson(json['score']), ); } @override @@ -1963,20 +2077,31 @@ class TeamTableData extends DataClass implements Insertable { 'id': serializer.toJson(id), 'name': serializer.toJson(name), 'createdAt': serializer.toJson(createdAt), + 'color': serializer.toJson(color), + 'score': serializer.toJson(score), }; } - TeamTableData copyWith({String? id, String? name, DateTime? createdAt}) => - TeamTableData( - id: id ?? this.id, - name: name ?? this.name, - createdAt: createdAt ?? this.createdAt, - ); + TeamTableData copyWith({ + String? id, + String? name, + DateTime? createdAt, + String? color, + Value score = const Value.absent(), + }) => TeamTableData( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + color: color ?? this.color, + score: score.present ? score.value : this.score, + ); TeamTableData copyWithCompanion(TeamTableCompanion data) { return TeamTableData( id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + color: data.color.present ? data.color.value : this.color, + score: data.score.present ? data.score.value : this.score, ); } @@ -1985,37 +2110,47 @@ class TeamTableData extends DataClass implements Insertable { return (StringBuffer('TeamTableData(') ..write('id: $id, ') ..write('name: $name, ') - ..write('createdAt: $createdAt') + ..write('createdAt: $createdAt, ') + ..write('color: $color, ') + ..write('score: $score') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, name, createdAt); + int get hashCode => Object.hash(id, name, createdAt, color, score); @override bool operator ==(Object other) => identical(this, other) || (other is TeamTableData && other.id == this.id && other.name == this.name && - other.createdAt == this.createdAt); + other.createdAt == this.createdAt && + other.color == this.color && + other.score == this.score); } class TeamTableCompanion extends UpdateCompanion { final Value id; final Value name; final Value createdAt; + final Value color; + final Value score; final Value rowid; const TeamTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), this.createdAt = const Value.absent(), + this.color = const Value.absent(), + this.score = const Value.absent(), this.rowid = const Value.absent(), }); TeamTableCompanion.insert({ required String id, required String name, required DateTime createdAt, + this.color = const Value.absent(), + this.score = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), @@ -2024,12 +2159,16 @@ class TeamTableCompanion extends UpdateCompanion { Expression? id, Expression? name, Expression? createdAt, + Expression? color, + Expression? score, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (name != null) 'name': name, if (createdAt != null) 'created_at': createdAt, + if (color != null) 'color': color, + if (score != null) 'score': score, if (rowid != null) 'rowid': rowid, }); } @@ -2038,12 +2177,16 @@ class TeamTableCompanion extends UpdateCompanion { Value? id, Value? name, Value? createdAt, + Value? color, + Value? score, Value? rowid, }) { return TeamTableCompanion( id: id ?? this.id, name: name ?? this.name, createdAt: createdAt ?? this.createdAt, + color: color ?? this.color, + score: score ?? this.score, rowid: rowid ?? this.rowid, ); } @@ -2060,6 +2203,12 @@ class TeamTableCompanion extends UpdateCompanion { if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } + if (color.present) { + map['color'] = Variable(color.value); + } + if (score.present) { + map['score'] = Variable(score.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2072,6 +2221,8 @@ class TeamTableCompanion extends UpdateCompanion { ..write('id: $id, ') ..write('name: $name, ') ..write('createdAt: $createdAt, ') + ..write('color: $color, ') + ..write('score: $score, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4092,6 +4243,7 @@ typedef $$MatchTableTableCreateCompanionBuilder = required String gameId, Value groupId, required String name, + Value isTeamMatch, required String notes, required DateTime createdAt, Value endedAt, @@ -4103,6 +4255,7 @@ typedef $$MatchTableTableUpdateCompanionBuilder = Value gameId, Value groupId, Value name, + Value isTeamMatch, Value notes, Value createdAt, Value endedAt, @@ -4215,6 +4368,11 @@ class $$MatchTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isTeamMatch => $composableBuilder( + column: $table.isTeamMatch, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get notes => $composableBuilder( column: $table.notes, builder: (column) => ColumnFilters(column), @@ -4346,6 +4504,11 @@ class $$MatchTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get isTeamMatch => $composableBuilder( + column: $table.isTeamMatch, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get notes => $composableBuilder( column: $table.notes, builder: (column) => ColumnOrderings(column), @@ -4423,6 +4586,11 @@ class $$MatchTableTableAnnotationComposer GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get isTeamMatch => $composableBuilder( + column: $table.isTeamMatch, + builder: (column) => column, + ); + GeneratedColumn get notes => $composableBuilder(column: $table.notes, builder: (column) => column); @@ -4566,6 +4734,7 @@ class $$MatchTableTableTableManager Value gameId = const Value.absent(), Value groupId = const Value.absent(), Value name = const Value.absent(), + Value isTeamMatch = const Value.absent(), Value notes = const Value.absent(), Value createdAt = const Value.absent(), Value endedAt = const Value.absent(), @@ -4575,6 +4744,7 @@ class $$MatchTableTableTableManager gameId: gameId, groupId: groupId, name: name, + isTeamMatch: isTeamMatch, notes: notes, createdAt: createdAt, endedAt: endedAt, @@ -4586,6 +4756,7 @@ class $$MatchTableTableTableManager required String gameId, Value groupId = const Value.absent(), required String name, + Value isTeamMatch = const Value.absent(), required String notes, required DateTime createdAt, Value endedAt = const Value.absent(), @@ -4595,6 +4766,7 @@ class $$MatchTableTableTableManager gameId: gameId, groupId: groupId, name: name, + isTeamMatch: isTeamMatch, notes: notes, createdAt: createdAt, endedAt: endedAt, @@ -5109,6 +5281,8 @@ typedef $$TeamTableTableCreateCompanionBuilder = required String id, required String name, required DateTime createdAt, + Value color, + Value score, Value rowid, }); typedef $$TeamTableTableUpdateCompanionBuilder = @@ -5116,6 +5290,8 @@ typedef $$TeamTableTableUpdateCompanionBuilder = Value id, Value name, Value createdAt, + Value color, + Value score, Value rowid, }); @@ -5171,6 +5347,16 @@ class $$TeamTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get score => $composableBuilder( + column: $table.score, + builder: (column) => ColumnFilters(column), + ); + Expression playerMatchTableRefs( Expression Function($$PlayerMatchTableTableFilterComposer f) f, ) { @@ -5220,6 +5406,16 @@ class $$TeamTableTableOrderingComposer column: $table.createdAt, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get score => $composableBuilder( + column: $table.score, + builder: (column) => ColumnOrderings(column), + ); } class $$TeamTableTableAnnotationComposer @@ -5240,6 +5436,12 @@ class $$TeamTableTableAnnotationComposer GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get color => + $composableBuilder(column: $table.color, builder: (column) => column); + + GeneratedColumn get score => + $composableBuilder(column: $table.score, builder: (column) => column); + Expression playerMatchTableRefs( Expression Function($$PlayerMatchTableTableAnnotationComposer a) f, ) { @@ -5297,11 +5499,15 @@ class $$TeamTableTableTableManager Value id = const Value.absent(), Value name = const Value.absent(), Value createdAt = const Value.absent(), + Value color = const Value.absent(), + Value score = const Value.absent(), Value rowid = const Value.absent(), }) => TeamTableCompanion( id: id, name: name, createdAt: createdAt, + color: color, + score: score, rowid: rowid, ), createCompanionCallback: @@ -5309,11 +5515,15 @@ class $$TeamTableTableTableManager required String id, required String name, required DateTime createdAt, + Value color = const Value.absent(), + Value score = const Value.absent(), Value rowid = const Value.absent(), }) => TeamTableCompanion.insert( id: id, name: name, createdAt: createdAt, + color: color, + score: score, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/data/db/tables/match_table.dart b/lib/data/db/tables/match_table.dart index c565547..221128e 100644 --- a/lib/data/db/tables/match_table.dart +++ b/lib/data/db/tables/match_table.dart @@ -12,6 +12,7 @@ class MatchTable extends Table { .references(GroupTable, #id, onDelete: KeyAction.setNull) .nullable()(); TextColumn get name => text()(); + BoolColumn get isTeamMatch => boolean().withDefault(const Constant(false))(); TextColumn get notes => text()(); DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get endedAt => dateTime().nullable()(); diff --git a/lib/data/db/tables/team_table.dart b/lib/data/db/tables/team_table.dart index b1a24a9..e706381 100644 --- a/lib/data/db/tables/team_table.dart +++ b/lib/data/db/tables/team_table.dart @@ -4,6 +4,8 @@ class TeamTable extends Table { TextColumn get id => text()(); TextColumn get name => text()(); DateTimeColumn get createdAt => dateTime()(); + TextColumn get color => text().withDefault(const Constant('blue'))(); + IntColumn get score => integer().nullable()(); @override Set> get primaryKey => {id}; diff --git a/lib/data/models/game.dart b/lib/data/models/game.dart index 89bbd30..44c7321 100644 --- a/lib/data/models/game.dart +++ b/lib/data/models/game.dart @@ -8,13 +8,13 @@ class Game { final String name; final Ruleset ruleset; final String description; - final GameColor color; + final AppColor color; final String icon; Game({ required this.name, required this.ruleset, - this.color = GameColor.orange, + this.color = AppColor.orange, this.description = '', this.icon = '', String? id, @@ -33,7 +33,7 @@ class Game { String? name, Ruleset? ruleset, String? description, - GameColor? color, + AppColor? color, String? icon, }) { return Game( @@ -73,7 +73,10 @@ class Game { orElse: () => Ruleset.singleWinner, ), description = json['description'], - color = GameColor.values.firstWhere((e) => e.name == json['color']), + color = AppColor.values.firstWhere( + (e) => e.name == json['color'], + orElse: () => AppColor.orange, + ), icon = json['icon']; Map toJson() => { diff --git a/lib/data/models/match.dart b/lib/data/models/match.dart index 2c43fe3..deedea5 100644 --- a/lib/data/models/match.dart +++ b/lib/data/models/match.dart @@ -16,9 +16,10 @@ class Match { final Game game; final Group? group; final List players; + final bool isTeamMatch; final List? teams; final String notes; - Map scores; + final Map scores; Match({ required this.name, @@ -26,6 +27,7 @@ class Match { required this.players, this.endedAt, this.group, + this.isTeamMatch = false, this.teams, this.notes = '', String? id, @@ -37,7 +39,7 @@ class Match { @override String toString() { - return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, notes: $notes, scores: $scores, mvp: $mvp}'; + return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, isTeamMatch: $isTeamMatch, teams: $teams, notes: $notes, scores: $scores, mvp: $mvp}'; } Match copyWith({ @@ -48,6 +50,7 @@ class Match { Game? game, Group? group, List? players, + bool? isTeamMatch, List? teams, String? notes, Map? scores, @@ -60,6 +63,7 @@ class Match { game: game ?? this.game, group: group ?? this.group, players: players ?? this.players, + isTeamMatch: isTeamMatch ?? this.isTeamMatch, teams: teams ?? this.teams, notes: notes ?? this.notes, scores: scores ?? this.scores, @@ -78,6 +82,7 @@ class Match { game == other.game && group == other.group && const DeepCollectionEquality().equals(players, other.players) && + isTeamMatch == other.isTeamMatch && const DeepCollectionEquality().equals(teams, other.teams) && notes == other.notes && const DeepCollectionEquality().equals(scores, other.scores); @@ -91,6 +96,7 @@ class Match { game, group, const DeepCollectionEquality().hash(players), + isTeamMatch, const DeepCollectionEquality().hash(teams), notes, const DeepCollectionEquality().hash(scores), @@ -107,11 +113,12 @@ class Match { name: '', ruleset: Ruleset.singleWinner, description: '', - color: GameColor.blue, + color: AppColor.blue, icon: '', ), group = null, players = [], + isTeamMatch = json['isTeamMatch'], teams = [], scores = json['scores'] != null ? (json['scores'] as Map).map( @@ -133,11 +140,13 @@ class Match { 'gameId': game.id, 'groupId': group?.id, 'playerIds': players.map((player) => player.id).toList(), + 'isTeamMatch': isTeamMatch, 'teams': teams?.map((team) => team.toJson()).toList(), 'scores': scores.map((key, value) => MapEntry(key, value?.toJson())), 'notes': notes, }; + // Most Valuable Player(s) based on the match's ruleset List get mvp { if (players.isEmpty || scores.isEmpty) return []; @@ -195,4 +204,59 @@ class Match { return playerScore.score == lowestScore; }).toList(); } + + // MVP for team-based matches (Most Valuable Team) + List get mvt { + if (teams == null || teams!.isEmpty) return []; + + switch (game.ruleset) { + case Ruleset.highestScore: + return _getHighestScoreTeam(); + + case Ruleset.lowestScore: + return _getLowestScoreTeam(); + + case Ruleset.singleWinner: + return _getHighestScoreTeam().take(1).toList(); + + case Ruleset.singleLoser: + return _getLowestScoreTeam().take(1).toList(); + + case Ruleset.multipleWinners: + return _getHighestScoreTeam(); + + case Ruleset.placement: + return _getHighestScoreTeam().take(1).toList(); + } + } + + List _getHighestScoreTeam() { + if (teams!.every((team) => team.score == null)) { + return []; + } + + final int highestScore = teams! + .map((team) => team.score) + .whereType() + .reduce((max, score) => score > max ? score : max); + + return teams!.where((team) { + return team.score == highestScore; + }).toList(); + } + + List _getLowestScoreTeam() { + if (teams!.every((team) => team.score == null)) { + return []; + } + + final int lowestScore = teams! + .map((team) => team.score) + .whereType() + .reduce((min, score) => score < min ? score : min); + + return teams!.where((team) { + return team.score == lowestScore; + }).toList(); + } } diff --git a/lib/data/models/team.dart b/lib/data/models/team.dart index b16e2ec..408e2a5 100644 --- a/lib/data/models/team.dart +++ b/lib/data/models/team.dart @@ -1,5 +1,6 @@ import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/models/player.dart'; import 'package:uuid/uuid.dart'; @@ -7,31 +8,39 @@ class Team { final String id; final String name; final DateTime createdAt; + final AppColor color; + final int? score; final List members; Team({ String? id, required this.name, DateTime? createdAt, + this.color = AppColor.blue, + this.score, required this.members, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(); @override String toString() { - return 'Team{id: $id, name: $name, members: $members}'; + return 'Team{id: $id, name: $name, color: $color, score: $score, members: $members}'; } Team copyWith({ String? id, String? name, DateTime? createdAt, + AppColor? color, + int? score, List? members, }) { return Team( id: id ?? this.id, name: name ?? this.name, createdAt: createdAt ?? this.createdAt, + color: color ?? this.color, + score: score ?? this.score, members: members ?? this.members, ); } @@ -44,6 +53,8 @@ class Team { id == other.id && name == other.name && createdAt == other.createdAt && + color == other.color && + score == other.score && const DeepCollectionEquality().equals(members, other.members); @override @@ -51,6 +62,8 @@ class Team { id, name, createdAt, + color, + score, const DeepCollectionEquality().hash(members), ); @@ -58,12 +71,19 @@ class Team { : id = json['id'], name = json['name'], createdAt = DateTime.parse(json['createdAt']), + color = AppColor.values.firstWhere( + (e) => e.name == json['color'], + orElse: () => AppColor.orange, + ), + score = json['score'] ?? 0, members = []; // Populated during import via DataTransferService Map toJson() => { 'id': id, 'name': name, 'createdAt': createdAt.toIso8601String(), + 'color': color.name, + 'score': score, 'memberIds': members.map((member) => member.id).toList(), }; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index a1ed4af..95acced 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,5 +1,6 @@ { "@@locale": "de", + "add_team": "Team hinzufügen", "all_players": "Alle Spieler:innen", "all_players_selected": "Alle Spieler:innen ausgewählt", "amount_of_matches": "Anzahl der Spiele", @@ -25,6 +26,7 @@ "create_match": "Spiel erstellen", "create_new_group": "Neue Gruppe erstellen", "create_new_match": "Neues Spiel erstellen", + "create_teams": "Teams erstellen", "created_on": "Erstellt am", "data": "Daten", "data_successfully_deleted": "Daten erfolgreich gelöscht", @@ -79,10 +81,12 @@ "live_edit_mode": "Live-Bearbeitungsmodus", "loser": "Verlierer:in", "lowest_score": "Niedrigste Punkte", + "manage_members": "Mitglieder bearbeiten", "match_in_progress": "Spiel läuft...", "match_name": "Spieltitel", "match_profile": "Spielprofil", "matches": "Spiele", + "member": "Mitglied", "members": "Mitglieder", "most_points": "Höchste Punkte", "multiple_winners": "Mehrere Gewinner:innen", @@ -92,6 +96,7 @@ "no_license_text_available": "Kein Lizenztext verfügbar", "no_licenses_found": "Keine Lizenzen gefunden", "no_matches_created_yet": "Noch keine Spiele erstellt", + "no_players_available": "Keine Spieler:innen verfügbar", "no_players_created_yet": "Noch keine Spieler:in erstellt", "no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden", "no_players_selected": "Keine Spieler:innen ausgewählt", @@ -99,6 +104,7 @@ "no_results_entered_yet": "Noch keine Ergebnisse eingetragen", "no_second_match_available": "Kein zweites Spiel verfügbar", "no_statistics_available": "Keine Statistiken verfügbar", + "no_teams_available": "Keine Teams verfügbar", "none": "Kein", "none_group": "Keine", "not_available": "Nicht verfügbar", @@ -112,6 +118,7 @@ "privacy_policy": "Datenschutzerklärung", "quick_create": "Schnellzugriff", "recent_matches": "Letzte Spiele", + "redistribute": "Neu verteilen", "result": "Ergebnis", "results": "Ergebnisse", "ruleset": "Regelwerk", @@ -133,6 +140,9 @@ "statistics": "Statistiken", "stats": "Statistiken", "successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt", + "team": "Team", + "team_match": "Teamspiel", + "teams": "Teams", "there_are_no_games_matching_your_search": "Es gibt keine Spielvorlagen, die deiner Suche entspricht", "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.", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5ae94cd..a5effcd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,5 +1,6 @@ { "@@locale": "en", + "add_team": "Add Team", "all_players": "All players", "all_players_selected": "All players selected", "amount_of_matches": "Amount of Matches", @@ -25,6 +26,7 @@ "create_match": "Create match", "create_new_group": "Create new group", "create_new_match": "Create new match", + "create_teams": "Create teams", "created_on": "Created on", "data": "Data", "data_successfully_deleted": "Data successfully deleted", @@ -79,10 +81,12 @@ "live_edit_mode": "Live Edit Mode", "loser": "Loser", "lowest_score": "Lowest Score", + "manage_members": "Manage Members", "match_in_progress": "Match in progress...", "match_name": "Match name", "match_profile": "Match Profile", "matches": "Matches", + "member": "Member", "members": "Members", "most_points": "Most Points", "multiple_winners": "Multiple Winners", @@ -92,6 +96,7 @@ "no_license_text_available": "No license text available", "no_licenses_found": "No licenses found", "no_matches_created_yet": "No matches created yet", + "no_players_available": "No players available", "no_players_created_yet": "No players created yet", "no_players_found_with_that_name": "No players found with that name", "no_players_selected": "No players selected", @@ -99,6 +104,7 @@ "no_results_entered_yet": "No results entered yet", "no_second_match_available": "No second match available", "no_statistics_available": "No statistics available", + "no_teams_available": "No teams available", "none": "None", "none_group": "None", "not_available": "Not available", @@ -112,6 +118,7 @@ "privacy_policy": "Privacy Policy", "quick_create": "Quick Create", "recent_matches": "Recent Matches", + "redistribute": "Redistribute", "results": "Results", "ruleset": "Ruleset", "ruleset_least_points": "Inverse scoring: the player with the fewest points wins.", @@ -141,6 +148,9 @@ } } }, + "team": "Team", + "team_match": "Team Match", + "teams": "Teams", "there_are_no_games_matching_your_search": "There are no games matching your search", "there_is_no_group_matching_your_search": "There is no group matching your search", "this_cannot_be_undone": "This can't be undone.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index dd538d5..27e9ea5 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -98,6 +98,12 @@ abstract class AppLocalizations { Locale('en'), ]; + /// No description provided for @add_team. + /// + /// In en, this message translates to: + /// **'Add Team'** + String get add_team; + /// No description provided for @all_players. /// /// In en, this message translates to: @@ -248,6 +254,12 @@ abstract class AppLocalizations { /// **'Create new match'** String get create_new_match; + /// No description provided for @create_teams. + /// + /// In en, this message translates to: + /// **'Create teams'** + String get create_teams; + /// No description provided for @created_on. /// /// In en, this message translates to: @@ -530,6 +542,12 @@ abstract class AppLocalizations { /// **'Lowest Score'** String get lowest_score; + /// No description provided for @manage_members. + /// + /// In en, this message translates to: + /// **'Manage Members'** + String get manage_members; + /// No description provided for @match_in_progress. /// /// In en, this message translates to: @@ -554,6 +572,12 @@ abstract class AppLocalizations { /// **'Matches'** String get matches; + /// No description provided for @member. + /// + /// In en, this message translates to: + /// **'Member'** + String get member; + /// No description provided for @members. /// /// In en, this message translates to: @@ -608,6 +632,12 @@ abstract class AppLocalizations { /// **'No matches created yet'** String get no_matches_created_yet; + /// No description provided for @no_players_available. + /// + /// In en, this message translates to: + /// **'No players available'** + String get no_players_available; + /// No description provided for @no_players_created_yet. /// /// In en, this message translates to: @@ -650,6 +680,12 @@ abstract class AppLocalizations { /// **'No statistics available'** String get no_statistics_available; + /// No description provided for @no_teams_available. + /// + /// In en, this message translates to: + /// **'No teams available'** + String get no_teams_available; + /// No description provided for @none. /// /// In en, this message translates to: @@ -728,6 +764,12 @@ abstract class AppLocalizations { /// **'Recent Matches'** String get recent_matches; + /// No description provided for @redistribute. + /// + /// In en, this message translates to: + /// **'Redistribute'** + String get redistribute; + /// No description provided for @results. /// /// In en, this message translates to: @@ -848,6 +890,24 @@ abstract class AppLocalizations { /// **'Successfully added player {playerName}'** String successfully_added_player(String playerName); + /// No description provided for @team. + /// + /// In en, this message translates to: + /// **'Team'** + String get team; + + /// No description provided for @team_match. + /// + /// In en, this message translates to: + /// **'Team Match'** + String get team_match; + + /// No description provided for @teams. + /// + /// In en, this message translates to: + /// **'Teams'** + String get teams; + /// No description provided for @there_are_no_games_matching_your_search. /// /// 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 7c5177a..ff95f6c 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -8,6 +8,9 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get add_team => 'Team hinzufügen'; + @override String get all_players => 'Alle Spieler:innen'; @@ -85,6 +88,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get create_new_match => 'Neues Spiel erstellen'; + @override + String get create_teams => 'Teams erstellen'; + @override String get created_on => 'Erstellt am'; @@ -240,6 +246,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get lowest_score => 'Niedrigste Punkte'; + @override + String get manage_members => 'Mitglieder bearbeiten'; + @override String get match_in_progress => 'Spiel läuft...'; @@ -252,6 +261,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get matches => 'Spiele'; + @override + String get member => 'Mitglied'; + @override String get members => 'Mitglieder'; @@ -279,6 +291,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get no_matches_created_yet => 'Noch keine Spiele erstellt'; + @override + String get no_players_available => 'Keine Spieler:innen verfügbar'; + @override String get no_players_created_yet => 'Noch keine Spieler:in erstellt'; @@ -301,6 +316,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get no_statistics_available => 'Keine Statistiken verfügbar'; + @override + String get no_teams_available => 'Keine Teams verfügbar'; + @override String get none => 'Kein'; @@ -340,6 +358,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recent_matches => 'Letzte Spiele'; + @override + String get redistribute => 'Neu verteilen'; + @override String get results => 'Ergebnisse'; @@ -407,6 +428,15 @@ class AppLocalizationsDe extends AppLocalizations { return 'Spieler:in $playerName erfolgreich hinzugefügt'; } + @override + String get team => 'Team'; + + @override + String get team_match => 'Teamspiel'; + + @override + String get teams => 'Teams'; + @override String get there_are_no_games_matching_your_search => 'Es gibt keine Spielvorlagen, die deiner Suche entspricht'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index bc083e5..1060a9d 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -8,6 +8,9 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get add_team => 'Add Team'; + @override String get all_players => 'All players'; @@ -85,6 +88,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get create_new_match => 'Create new match'; + @override + String get create_teams => 'Create teams'; + @override String get created_on => 'Created on'; @@ -240,6 +246,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get lowest_score => 'Lowest Score'; + @override + String get manage_members => 'Manage Members'; + @override String get match_in_progress => 'Match in progress...'; @@ -252,6 +261,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get matches => 'Matches'; + @override + String get member => 'Member'; + @override String get members => 'Members'; @@ -279,6 +291,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get no_matches_created_yet => 'No matches created yet'; + @override + String get no_players_available => 'No players available'; + @override String get no_players_created_yet => 'No players created yet'; @@ -301,6 +316,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get no_statistics_available => 'No statistics available'; + @override + String get no_teams_available => 'No teams available'; + @override String get none => 'None'; @@ -340,6 +358,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recent_matches => 'Recent Matches'; + @override + String get redistribute => 'Redistribute'; + @override String get results => 'Results'; @@ -407,6 +428,15 @@ class AppLocalizationsEn extends AppLocalizations { return 'Successfully added player $playerName'; } + @override + String get team => 'Team'; + + @override + String get team_match => 'Team Match'; + + @override + String get teams => 'Teams'; + @override String get there_are_no_games_matching_your_search => 'There are no games matching your search'; 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 84efbe1..96092ca 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 @@ -8,7 +8,7 @@ import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; -import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; import 'package:tallee/presentation/widgets/player_selection.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; @@ -96,19 +96,24 @@ class _CreateGroupViewState extends State { }, ), ), - CustomWidthButton( - text: widget.groupToEdit == null - ? loc.create_group - : loc.edit_group, - sizeRelativeToWidth: 0.95, - buttonType: ButtonType.primary, - onPressed: - (_groupNameController.text.isEmpty || - (selectedPlayers.length < 2)) - ? null - : _saveGroup, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: AnimatedDialogButton( + buttonConstraints: const BoxConstraints( + minWidth: double.infinity, + minHeight: 50, + ), + buttonText: widget.groupToEdit == null + ? loc.create_group + : loc.edit_group, + buttonType: ButtonType.primary, + onPressed: + (_groupNameController.text.isEmpty || + (selectedPlayers.length < 2)) + ? null + : _saveGroup, + ), ), - const SizedBox(height: 20), ], ), ), 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 c417ec4..47f4b3f 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 @@ -150,7 +150,6 @@ class _GroupDetailViewState extends State { return TextIconTile( text: member.name, suffixText: getNameCountText(member), - iconEnabled: false, ); }).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 3c51cab..a4a7f2e 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 @@ -51,6 +51,9 @@ class _ChooseGameViewState extends State { /// Games filtered according to the current search query late List filteredGames; + List get games => + widget.games..sort((a, b) => a.name.compareTo(b.name)); + @override void initState() { db = Provider.of(context, listen: false); @@ -59,7 +62,7 @@ class _ChooseGameViewState extends State { selectedGameId = widget.initialGameId; // Start with all games visible - filteredGames = List.from(widget.games); + filteredGames = List.from(games); super.initState(); } @@ -77,9 +80,7 @@ class _ChooseGameViewState extends State { Navigator.of(context).pop( selectedGameId == '' ? null - : widget.games.firstWhere( - (game) => game.id == selectedGameId, - ), + : games.firstWhere((game) => game.id == selectedGameId), ); }, ), @@ -99,7 +100,7 @@ class _ChooseGameViewState extends State { ); if (result != null && result.game != null) { setState(() { - widget.games.insert(0, result.game); + games.insert(0, result.game); }); _refreshFromSource(); } @@ -139,7 +140,7 @@ class _ChooseGameViewState extends State { child: Visibility( visible: filteredGames.isNotEmpty, replacement: Visibility( - visible: widget.games.isNotEmpty, + visible: games.isNotEmpty, replacement: TopCenteredMessage( icon: Icons.info, title: loc.info, @@ -160,10 +161,7 @@ class _ChooseGameViewState extends State { return GameTile( title: game.name, description: game.description, - badgeText: translateRulesetToString( - game.ruleset, - context, - ), + subtitle: translateRulesetToString(game.ruleset, context), badgeColor: getColorFromGameColor(game.color), isHighlighted: selectedGameId == game.id, onTap: () async { @@ -190,7 +188,7 @@ class _ChooseGameViewState extends State { ); if (result != null && result.game != null) { // Find the index in the original list to mutate - final originalIndex = widget.games.indexWhere( + final originalIndex = games.indexWhere( (g) => g.id == game.id, ); if (originalIndex == -1) { @@ -202,12 +200,12 @@ class _ChooseGameViewState extends State { if (selectedGameId == game.id) { selectedGameId = ''; } - widget.games.removeAt(originalIndex); + games.removeAt(originalIndex); widget.onGamesUpdated?.call(); }); } else { setState(() { - widget.games[originalIndex] = result.game; + games[originalIndex] = result.game; }); } _refreshFromSource(); @@ -229,13 +227,13 @@ class _ChooseGameViewState extends State { final q = query.toLowerCase().trim(); if (q.isEmpty) { setState(() { - filteredGames = List.from(widget.games); + filteredGames = List.from(games); }); return; } setState(() { - filteredGames = widget.games.where((game) { + filteredGames = games.where((game) { final name = game.name.toLowerCase(); final description = game.description.toLowerCase(); return name.contains(q) || description.contains(q); diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_game_view.dart index 998f4e1..21849bf 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/create_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/create_game_view.dart @@ -49,10 +49,10 @@ class _CreateGameViewState extends State { late final AppDatabase db; late List<(Ruleset, String)> _rulesets; - late List<(GameColor, String)> _colors; + late List<(AppColor, String)> _colors; Ruleset? selectedRuleset = Ruleset.singleWinner; - GameColor? selectedColor = GameColor.orange; + AppColor? selectedColor = AppColor.orange; /// Controller for the game name input field. final _gameNameController = TextEditingController(); @@ -87,10 +87,10 @@ class _CreateGameViewState extends State { ), ); _colors = List.generate( - GameColor.values.length, + AppColor.values.length, (index) => ( - GameColor.values[index], - translateGameColorToString(GameColor.values[index], context), + AppColor.values[index], + translateGameColorToString(AppColor.values[index], context), ), ); 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 85bb936..7fd9fa0 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 @@ -12,8 +12,9 @@ import 'package:tallee/data/models/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/choose_game_view.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/choose_group_view.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_teams/create_teams_view.dart'; import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; -import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; import 'package:tallee/presentation/widgets/player_selection.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; @@ -59,6 +60,7 @@ class _CreateMatchViewState extends State { Group? selectedGroup; Game? selectedGame; + bool isTeamMatch = false; List selectedPlayers = []; /// GlobalKey for ScaffoldMessenger to show snackbars @@ -135,24 +137,7 @@ class _CreateMatchViewState extends State { trailing: selectedGame == null ? Text(loc.none_group) : Text(selectedGame!.name), - onPressed: () async { - selectedGame = await Navigator.of(context).push( - adaptivePageRoute( - builder: (context) => ChooseGameView( - games: gamesList, - initialGameId: selectedGame?.id ?? '', - onGamesUpdated: widget.onMatchesUpdated, - ), - ), - ); - setState(() { - if (selectedGame != null) { - hintText = selectedGame!.name; - } else { - hintText = loc.match_name; - } - }); - }, + onPressed: () async => await onChoosingGame(), ), // Group selection tile. @@ -161,36 +146,20 @@ class _CreateMatchViewState extends State { trailing: selectedGroup == null ? Text(loc.none_group) : Text(selectedGroup!.name), - onPressed: () async { - // Remove all players from the previously selected group from - // the selected players list, in case the user deselects the - // group or selects a different group. - selectedPlayers.removeWhere( - (player) => - selectedGroup?.members.any( - (member) => member.id == player.id, - ) ?? - false, - ); - selectedGroup = await Navigator.of(context).push( - adaptivePageRoute( - builder: (context) => ChooseGroupView( - groups: groupsList, - initialGroupId: selectedGroup?.id ?? '', - ), - ), - ); - - setState(() { - if (selectedGroup != null) { - setState(() { - selectedPlayers += [...selectedGroup!.members]; - }); - } - }); - }, + onPressed: () async => onChoosingGroup(), ), + if (!isEditMode()) + ChooseTile( + title: loc.team_match, + trailing: Switch.adaptive( + activeTrackColor: CustomTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: -15), + value: isTeamMatch, + onChanged: (value) => setState(() => isTeamMatch = value), + ), + ), + // Player selection widget. Expanded( child: PlayerSelection( @@ -206,15 +175,21 @@ class _CreateMatchViewState extends State { ), // Create or save button. - CustomWidthButton( - text: buttonText, - sizeRelativeToWidth: 0.95, - buttonType: ButtonType.primary, - onPressed: _enableCreateGameButton() - ? () { - buttonNavigation(context); - } - : null, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: AnimatedDialogButton( + buttonConstraints: const BoxConstraints( + minWidth: double.infinity, + minHeight: 50, + ), + buttonType: ButtonType.primary, + onPressed: isSubmitButtonEnabled() + ? () { + submitButtonNavigation(context); + } + : null, + buttonText: buttonText, + ), ), ], ), @@ -227,12 +202,86 @@ class _CreateMatchViewState extends State { return widget.matchToEdit != null; } + // 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; + selectedGame = match.game; + + if (match.group != null) { + selectedGroup = match.group; + } + } + + Future onChoosingGame() async { + selectedGame = await Navigator.of(context).push( + adaptivePageRoute( + builder: (context) => ChooseGameView( + games: gamesList, + initialGameId: selectedGame?.id ?? '', + onGamesUpdated: widget.onMatchesUpdated, + ), + ), + ); + setState(() { + if (selectedGame != null) { + hintText = selectedGame!.name; + } else { + hintText = AppLocalizations.of(context).match_name; + } + }); + } + + Future onChoosingGroup() async { + // Remove all players from the previously selected group from + // the selected players list, in case the user deselects the + // group or selects a different group. + selectedPlayers.removeWhere( + (player) => + selectedGroup?.members.any((member) => member.id == player.id) ?? + false, + ); + + selectedGroup = await Navigator.of(context).push( + adaptivePageRoute( + builder: (context) => ChooseGroupView( + groups: groupsList, + initialGroupId: selectedGroup?.id ?? '', + ), + ), + ); + + setState(() { + if (selectedGroup != null) { + setState(() { + selectedPlayers += [...selectedGroup!.members]; + }); + } + }); + } + + // If none of the selected players are from the currently selected group, + // the group is also deselected. + Future removeGroupWhenNoMemberLeft() async { + if (selectedGroup == null) return; + + if (!selectedPlayers.any( + (player) => + selectedGroup!.members.any((member) => member.id == player.id), + )) { + setState(() { + selectedGroup = null; + }); + } + } + /// Determines whether the "Create Match" button should be enabled. /// /// Returns `true` if: /// - A game is selected AND /// - Either a group is selected OR at least 2 players are selected. - bool _enableCreateGameButton() { + bool isSubmitButtonEnabled() { return ((selectedGroup != null || selectedPlayers.length > 1) && selectedGame != null); } @@ -241,20 +290,35 @@ class _CreateMatchViewState extends State { /// /// If a match is being edited, updates the match in the database. /// Otherwise, creates a new match and navigates to the MatchResultView. - void buttonNavigation(BuildContext context) async { + void submitButtonNavigation(BuildContext context) async { if (isEditMode()) { await updateMatch(); if (context.mounted) { Navigator.pop(context); } - } else { - final match = await createMatch(); + } + final match = await createMatch(); + + if (isTeamMatch) { + if (context.mounted) { + Navigator.push( + context, + adaptivePageRoute( + fullscreenDialog: !isTeamMatch, + builder: (context) => CreateTeamsView( + match: match, + onWinnerChanged: widget.onWinnerChanged, + ), + ), + ); + } + } else { if (context.mounted) { Navigator.pushReplacement( context, adaptivePageRoute( - fullscreenDialog: true, + fullscreenDialog: !isTeamMatch, builder: (context) => MatchResultView( match: match, onWinnerChanged: widget.onWinnerChanged, @@ -327,36 +391,12 @@ class _CreateMatchViewState extends State { createdAt: DateTime.now(), group: selectedGroup, players: selectedPlayers, + isTeamMatch: isTeamMatch, game: selectedGame!, ); - await db.matchDao.addMatch(match: match); + + // Team matches are saved in OrganizeTeamsView + if (!isTeamMatch) await db.matchDao.addMatch(match: match); return match; } - - // 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; - selectedGame = match.game; - - if (match.group != null) { - selectedGroup = match.group; - } - } - - // If none of the selected players are from the currently selected group, - // the group is also deselected. - Future removeGroupWhenNoMemberLeft() async { - if (selectedGroup == null) return; - - if (!selectedPlayers.any( - (player) => - selectedGroup!.members.any((member) => member.id == player.id), - )) { - setState(() { - selectedGroup = null; - }); - } - } } diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_teams/create_teams_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_teams/create_teams_view.dart new file mode 100644 index 0000000..f33eea5 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/create_teams/create_teams_view.dart @@ -0,0 +1,190 @@ +import 'dart:math'; + +import 'package:flutter/material.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/data/models/match.dart'; +import 'package:tallee/data/models/player.dart'; +import 'package:tallee/data/models/team.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_teams/manage_members_view.dart'; +import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; +import 'package:tallee/presentation/widgets/tiles/team_creation_tile.dart'; + +class CreateTeamsView extends StatefulWidget { + const CreateTeamsView({super.key, required this.match, this.onWinnerChanged}); + + final Match match; + final VoidCallback? onWinnerChanged; + + @override + State createState() => _CreateTeamsViewState(); +} + +class _CreateTeamsViewState extends State { + final Random random = Random(); + List get matchPlayers => widget.match.players; + + late List teams; + late List nameController; + final int initialTeamCount = 2; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final loc = AppLocalizations.of(context); + + // Init the teams + teams = List.generate( + initialTeamCount, + (index) => Team( + name: '${loc.team} ${index + 1}', + color: getTeamColor(index), + members: [], + ), + ); + + // Init the controllers + nameController = teams.map(getNewController).toList(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar(title: Text(loc.create_teams)), + body: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: ListView.builder( + padding: const EdgeInsets.only(top: 12, bottom: 96), + itemCount: teams.length, + itemBuilder: (context, index) { + return TeamCreationTile( + color: teams[index].color, + controller: nameController[index], + hintText: '${loc.team} ${index + 1}', + onDelete: teams.length <= 2 ? null : () => removeTeam(index), + onColorSelection: (color) { + setState(() { + teams[index] = teams[index].copyWith(color: color); + }); + }, + ); + }, + ), + ), + + // Button row + Positioned( + bottom: MediaQuery.paddingOf(context).bottom + 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Add new team + MainMenuButton( + icon: Icons.add, + text: loc.add_team, + onPressed: teams.length >= widget.match.players.length + ? null + : addTeam, + ), + const SizedBox(width: 15), + + // Confirm teams + MainMenuButton( + icon: Icons.arrow_forward_sharp, + onPressed: teams.length >= 2 + ? () { + final match = widget.match.copyWith(teams: teams); + Navigator.push( + context, + adaptivePageRoute( + builder: (context) => ManageMembersView( + match: match, + onWinnerChanged: widget.onWinnerChanged, + ), + ), + ); + } + : null, + ), + ], + ), + ), + ], + ), + ); + } + + /// Creates a new team with a default name and color based on the current number + Team getNewTeam() { + final loc = AppLocalizations.of(context); + return Team( + name: '${loc.team} ${teams.length + 1}', + color: getTeamColor(teams.length), + members: [], + ); + } + + /// Builds a [TextEditingController] for the given team and sets up a listener + /// to update the team's name whenever the text changes. + TextEditingController getNewController(Team team) { + final textController = TextEditingController(text: team.name); + textController.addListener(() { + final index = teams.indexWhere((t) => t.id == team.id); + if (index == -1) return; + teams[index] = teams[index].copyWith(name: textController.text); + }); + return textController; + } + + /// Adds a new team to the list of teams, creates a corresponding controller, + /// and redistributes the players among all teams. + void addTeam() { + setState(() { + final newTeam = getNewTeam(); + teams.add(newTeam); + nameController.add(getNewController(newTeam)); + }); + } + + /// Removes the team with the given index. If there are less than 2 teams the + /// removed team gets replaced with a new one + void removeTeam(int index) { + final loc = AppLocalizations.of(context); + + setState(() { + teams.removeAt(index); + final removedController = nameController.removeAt(index); + removedController.dispose(); + + // Update index-based team names and default colors + for (int i = 0; i < nameController.length; i++) { + if (nameController[i].text.contains( + RegExp('^${RegExp.escape(loc.team)} \\d+\$'), + )) { + nameController[i].text = '${loc.team} ${i + 1}'; + + // Reset color to default if it was based on the index + final previousIndex = i < index ? i : i + 1; + if (teams[i].color == getTeamColor(previousIndex)) { + teams[i] = teams[i].copyWith(color: getTeamColor(i)); + } + } + } + }); + } + + @override + void dispose() { + for (final c in nameController) { + c.dispose(); + } + super.dispose(); + } +} diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_teams/manage_members_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_teams/manage_members_view.dart new file mode 100644 index 0000000..7d2f2d7 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/create_teams/manage_members_view.dart @@ -0,0 +1,306 @@ +import 'dart:core' hide Match; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_numeric_text/flutter_numeric_text.dart'; +import 'package:fluttericon/rpg_awesome_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/data/db/database.dart'; +import 'package:tallee/data/models/match.dart'; +import 'package:tallee/data/models/team.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; +import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; +import 'package:tallee/presentation/widgets/tiles/text_icon_list_tile.dart'; + +/// Displays the given [teams] as a flat reorderable list where every team is +/// preceded by a header row and followed by its members. Members can be +/// dragged across team boundaries to be reassigned to another team. +class ManageMembersView extends StatefulWidget { + const ManageMembersView({ + super.key, + required this.match, + required this.onWinnerChanged, + }); + + final Match match; + + final VoidCallback? onWinnerChanged; + + @override + State createState() => _ManageMembersViewState(); +} + +class _ManageMembersViewState extends State { + late AppDatabase db; + + List get teams => widget.match.teams!; + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + redistributePlayers(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar(title: Text(loc.manage_members)), + body: Stack( + alignment: AlignmentDirectional.center, + children: [ + Positioned.fill( + child: ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 96), + buildDefaultDragHandles: false, + itemCount: allItemsCount, + onReorderItem: onReorderItem, + proxyDecorator: (child, index, animation) => + Material(type: MaterialType.transparency, child: child), + itemBuilder: (context, index) { + final teamIndex = teamIndexForFlat(index); + final memberIndex = memberIndexForFlat(index, teamIndex); + final team = teams[teamIndex]; + + if (memberIndex == -1) { + return buildTeamTile(team: team); + } + + final player = team.members[memberIndex]; + return ReorderableDelayedDragStartListener( + key: ValueKey('player_${player.id}'), + index: index, + child: TextIconListTile( + text: player.name, + suffixText: getNameCountText(player), + icon: Icons.drag_handle, + ), + ); + }, + ), + ), + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MainMenuButton( + onPressed: () => setState(() { + redistributePlayers(); + }), + icon: Icons.cached, + ), + const SizedBox(width: 16), + MainMenuButton( + onPressed: allTeamsHaveMembers + ? () async => submitMatch() + : null, + text: loc.create_match, + icon: RpgAwesome.clovers_card, + ), + ], + ), + ), + ], + ), + ); + } + + Widget buildTeamTile({required Team team}) { + final color = getColorFromGameColor(team.color); + final loc = AppLocalizations.of(context); + final length = team.members.length; + final memberText = length == 1 ? loc.member : loc.members; + + return Padding( + key: ValueKey(team.id), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Row( + children: [ + // Color circle + Container( + width: 14, + height: 14, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + + const SizedBox(width: 10), + + // Team name + Expanded( + child: Text( + team.name, + style: const TextStyle( + color: CustomTheme.textColor, + fontSize: 17, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + + // Member length + SizedBox( + width: 150, + child: NumericText( + '$length $memberText', + duration: const Duration(milliseconds: 200), + maxLines: 1, + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: CustomTheme.hintColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + // Iterates through all teams and redistributes players randomly and + // as evenly as possible. + void redistributePlayers() { + for (final team in teams) { + team.members.clear(); + } + var matchPlayers = widget.match.players; + Random random = Random(); + + if (matchPlayers.isEmpty || teams.isEmpty) { + return; + } + + final shuffledPlayers = [...matchPlayers]..shuffle(random); + + for (int i = 0; i < shuffledPlayers.length; i++) { + final teamIndex = i % teams.length; + teams[teamIndex].members.add(shuffledPlayers[i]); + } + } + + /// Handles moving a member from one team to another + void onReorderItem(int oldIndex, int newIndex) { + final sourceTeamIndex = teamIndexForFlat(oldIndex); + final sourceMemberIndex = memberIndexForFlat(oldIndex, sourceTeamIndex); + + // Headers themselves can't be reordered. + if (sourceMemberIndex == -1) return; + + // When moving down, the target index is shifted by 1 + // because the item is removed first. + var targetIndex = newIndex; + if (newIndex > oldIndex) targetIndex -= 1; + targetIndex = targetIndex.clamp(0, allItemsCount - 1); + + // Resolve target location based on the item currently + // at targetIndex before the move. + int destTeamIndex; + int insertPositionInTeam; + + if (targetIndex >= allItemsCount - 1 && newIndex >= allItemsCount) { + // dropped at the very end, append to the last team. + destTeamIndex = teams.length - 1; + insertPositionInTeam = teams[destTeamIndex].members.length; + } else { + destTeamIndex = teamIndexForFlat(targetIndex); + final anchorMemberIndex = memberIndexForFlat(targetIndex, destTeamIndex); + + if (anchorMemberIndex == -1) { + // dropped on a header, direction decides which team the player gets added + // if moving down, insert as first member of that team. + // if moving UP, append to the previous team. + final isMovingDown = newIndex > oldIndex; + if (isMovingDown) { + insertPositionInTeam = 0; + } else { + final previousTeamIndex = destTeamIndex - 1; + if (previousTeamIndex < 0) { + // above the very first header, stay at top of team 0. + insertPositionInTeam = 0; + } else { + destTeamIndex = previousTeamIndex; + insertPositionInTeam = teams[destTeamIndex].members.length; + } + } + } else { + insertPositionInTeam = anchorMemberIndex; + } + } + + setState(() { + final sourceMembers = teams[sourceTeamIndex].members; + final player = sourceMembers.removeAt(sourceMemberIndex); + + // Adjust insert index if removed from before the insert point in the + // same team. + if (sourceTeamIndex == destTeamIndex && + insertPositionInTeam > sourceMembers.length) { + insertPositionInTeam = sourceMembers.length; + } + + teams[destTeamIndex].members.insert(insertPositionInTeam, player); + }); + } + + /// Total players + teams length + int get allItemsCount { + var count = 0; + for (final team in teams) { + count += 1 + team.members.length; + } + return count; + } + + /// Returns the index of the team that owns the flat-list item at [flatIndex]. + int teamIndexForFlat(int flatIndex) { + var remaining = flatIndex; + for (var i = 0; i < teams.length; i++) { + final size = 1 + teams[i].members.length; + if (remaining < size) return i; + remaining -= size; + } + return teams.length - 1; + } + + /// Returns the member index within its team, or `-1` if the item at + /// [flatIndex] is the team header. + int memberIndexForFlat(int flatIndex, int teamIndex) { + var offset = 0; + for (var i = 0; i < teamIndex; i++) { + offset += 1 + teams[i].members.length; + } + // offset now points to the header of [teamIndex]. Anything beyond is a + // member of that team. + final localIndex = flatIndex - offset; + return localIndex == 0 ? -1 : localIndex - 1; + } + + bool get allTeamsHaveMembers => + teams.every((team) => team.members.isNotEmpty); + + void submitMatch() async { + final match = widget.match; + await db.matchDao.addMatch(match: match); + if (mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => MatchResultView( + match: match, + onWinnerChanged: widget.onWinnerChanged, + ), + ), + (route) => route.isFirst, + ); + } + } +} diff --git a/lib/presentation/views/main_menu/match_view/create_match/match_result/live_edit_view.dart b/lib/presentation/views/main_menu/match_view/create_match/match_result/live_edit_view.dart new file mode 100644 index 0000000..5b960c6 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/create_match/match_result/live_edit_view.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:tallee/data/models/match.dart'; +import 'package:tallee/data/models/player.dart'; +import 'package:tallee/data/models/team.dart'; +import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart'; +import 'package:tallee/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart'; + +class LiveEditView extends StatefulWidget { + const LiveEditView({super.key, required this.match}); + final Match match; + + @override + State createState() => _LiveEditViewState(); +} + +class _LiveEditViewState extends State { + List get allTeams => + (widget.match.teams ?? [])..sort((a, b) => a.name.compareTo(b.name)); + List get allPlayers => + widget.match.players..sort((a, b) => a.name.compareTo(b.name)); + List scores = []; + + @override + void initState() { + super.initState(); + + if (widget.match.isTeamMatch) { + scores = List.generate( + allTeams.length, + (index) => allTeams[index].score ?? 0, + ); + } else { + scores = List.generate( + allPlayers.length, + (index) => widget.match.scores[allPlayers[index].id]?.score ?? 0, + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.match.name), + leading: HapticIconButton( + onPressed: () => Navigator.pop(context, scores), + icon: const Icon(Icons.close), + ), + ), + body: Column( + children: [ + Expanded(child: buildLiveEditWidget(widget.match.isTeamMatch)), + ], + ), + ); + } + + Widget buildLiveEditWidget(bool isTeamMatch) { + if (isTeamMatch) { + return ListView.builder( + itemCount: allTeams.length, + itemBuilder: (context, index) { + return LiveEditListTile( + title: allTeams[index].name, + onChanged: (value) { + scores[index] = value; + }, + value: scores[index], + ); + }, + ); + } else { + return ListView.builder( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return LiveEditListTile( + title: allPlayers[index].name, + onChanged: (value) { + setState(() { + scores[index] = value; + }); + }, + value: scores[index], + ); + }, + ); + } + } +} 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 b34f9df..c6272b4 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 @@ -13,6 +13,7 @@ import 'package:tallee/presentation/views/main_menu/match_view/create_match/crea import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; +import 'package:tallee/presentation/widgets/cards/team_card.dart'; import 'package:tallee/presentation/widgets/colored_icon_container.dart'; import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart'; import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart'; @@ -43,13 +44,13 @@ class MatchDetailView extends StatefulWidget { class _MatchDetailViewState extends State { late final AppDatabase db; - late Match match; + late Match localMatch; @override void initState() { super.initState(); db = Provider.of(context, listen: false); - match = widget.match; + localMatch = widget.match; } @override @@ -83,7 +84,7 @@ class _MatchDetailViewState extends State { ), ).then((confirmed) async { if (confirmed! && context.mounted) { - await db.matchDao.deleteMatch(matchId: match.id); + await db.matchDao.deleteMatch(matchId: localMatch.id); if (!context.mounted) return; Navigator.pop(context); widget.onMatchUpdate.call(); @@ -117,7 +118,7 @@ class _MatchDetailViewState extends State { // Match Name Text( - match.name, + localMatch.name, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, @@ -129,7 +130,7 @@ class _MatchDetailViewState extends State { // Creation Date Text( - '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(match.createdAt)}', + '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(localMatch.createdAt)}', style: const TextStyle( fontSize: 12, color: CustomTheme.textColor, @@ -139,14 +140,14 @@ class _MatchDetailViewState extends State { const SizedBox(height: 10), // Group Name - if (match.group != null) ...[ + if (localMatch.group != null) ...[ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.group), const SizedBox(width: 8), Text( - '${match.group!.name}${getExtraPlayerCount(match)}', + '${localMatch.group!.name}${getExtraPlayerCount(localMatch)}', style: const TextStyle(fontWeight: FontWeight.bold), ), ], @@ -154,25 +155,60 @@ class _MatchDetailViewState extends State { const SizedBox(height: 20), ], - // Players - InfoTile( - title: loc.players, - icon: Icons.people, - horizontalAlignment: CrossAxisAlignment.start, - content: Wrap( - alignment: WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - spacing: 12, - runSpacing: 8, - children: match.players.map((player) { - return TextIconTile( - text: player.name, - suffixText: getNameCountText(player), - iconEnabled: false, - ); - }).toList(), + // Teams or Players + if (localMatch.isTeamMatch) ...[ + // Teams + InfoTile( + title: loc.teams, + icon: Icons.scoreboard, + horizontalAlignment: CrossAxisAlignment.start, + content: + localMatch.teams != null && localMatch.teams!.isNotEmpty + ? Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: (localMatch.teams ?? []).map((team) { + return TeamCard(team: team); + }).toList(), + ) + : Text( + loc.no_teams_available, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.textColor, + ), + ), ), - ), + ] else ...[ + // Players + InfoTile( + title: loc.players, + icon: Icons.people, + horizontalAlignment: CrossAxisAlignment.start, + content: localMatch.players.isNotEmpty + ? Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: localMatch.players.map((player) { + return TextIconTile( + text: player.name, + suffixText: getNameCountText(player), + ); + }).toList(), + ) + : Text( + loc.no_players_available, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.textColor, + ), + ), + ), + ], const SizedBox(height: 15), // Game @@ -186,12 +222,12 @@ class _MatchDetailViewState extends State { horizontal: 8, ), child: GameLabel( - title: match.game.name, + title: localMatch.game.name, description: translateRulesetToString( - match.game.ruleset, + localMatch.game.ruleset, context, ), - color: match.game.color, + color: localMatch.game.color, ), ), ), @@ -222,7 +258,7 @@ class _MatchDetailViewState extends State { adaptivePageRoute( fullscreenDialog: true, builder: (context) => CreateMatchView( - matchToEdit: match, + matchToEdit: localMatch, onMatchUpdated: onMatchUpdated, ), ), @@ -238,12 +274,10 @@ class _MatchDetailViewState extends State { adaptivePageRoute( fullscreenDialog: true, builder: (context) => MatchResultView( - match: match, - onWinnerChanged: () { + match: localMatch, + onWinnerChanged: () async { widget.onMatchUpdate.call(); - setState(() { - updateScoresForCurrentMatch(); - }); + await updateScoresForCurrentMatch(); }, ), ), @@ -263,7 +297,7 @@ class _MatchDetailViewState extends State { /// updates the match in this view void onMatchUpdated(Match editedMatch) { setState(() { - match = editedMatch; + localMatch = editedMatch; }); widget.onMatchUpdate.call(); } @@ -284,95 +318,113 @@ class _MatchDetailViewState extends State { /// Returns the result row for single winner/loser rulesets or a placeholder /// if no result is entered yet List getSingleResultRow(AppLocalizations loc) { - if (match.mvp.isNotEmpty) { - final ruleset = match.game.ruleset; + final ruleset = localMatch.game.ruleset; - if (ruleset == Ruleset.singleWinner || ruleset == Ruleset.singleLoser) { - return [ - Text( - ruleset == Ruleset.singleWinner ? loc.winner : loc.loser, - style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), - ), - Text( - match.mvp.first.name, + if (localMatch.mvp.isNotEmpty || localMatch.mvt.isNotEmpty) { + // Single winner/loser, multiple winner + final names = localMatch.isTeamMatch + ? localMatch.mvt.map((t) => t.name).toList() + : localMatch.mvp.map((p) => p.name).toList(); + final mvpNames = names.length == 1 ? names.first : names.join(', '); + + final label = ruleset == Ruleset.singleWinner + ? loc.winner + : ruleset == Ruleset.singleLoser + ? loc.loser + : loc.winners; + + return [ + Text( + label, + style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), + ), + SizedBox( + width: 200, + child: Text( + mvpNames, + textAlign: TextAlign.end, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: CustomTheme.primaryColor, ), ), - ]; - } else if (match.game.ruleset == Ruleset.multipleWinners) { - return [ - Text( - loc.winners, - style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), - ), - Flexible( - child: Container( - padding: const EdgeInsets.only(left: 10), - child: Text( - match.mvp.map((player) => player.name).join(', '), - textAlign: TextAlign.end, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: CustomTheme.primaryColor, - ), - ), - ), - ), - ]; - } + ), + ]; + } else { + // No result yet + return [ + Text( + loc.no_results_entered_yet, + style: const TextStyle(fontSize: 14, color: CustomTheme.textColor), + ), + ]; } - - // No results yet - return [ - Text( - loc.no_results_entered_yet, - style: const TextStyle(fontSize: 14, color: CustomTheme.textColor), - ), - ]; } /// Returns the result widget for scores or placement Widget getMultiResultRows(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)); - } - - final ruleset = match.game.ruleset; - - if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) { - playerScores.sort((a, b) => b.$2.compareTo(a.$2)); - } else if (ruleset == Ruleset.lowestScore) { - playerScores.sort((a, b) => a.$2.compareTo(b.$2)); - } + List<(String, int)> scores = getSortedScores(); return Column( children: [ - for (var i = 0; i < playerScores.length; i++) + for (var i = 0; i < scores.length; i++) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - playerScores[i].$1, + scores[i].$1, style: const TextStyle( fontSize: 16, color: CustomTheme.textColor, ), ), - getResultValueText(loc, i, playerScores[i].$2), + getResultValueText(loc, i, scores[i].$2), ], ), ], ); } + /// Returns a list of player/team names and their corresponding scores, sorted by score according to the ruleset + List<(String, int)> getSortedScores() { + List<(String, int)> namedScores = []; + + if (localMatch.isTeamMatch) { + final teams = localMatch.teams ?? []; + for (var team in teams) { + int score = team.score ?? 0; + namedScores.add((team.name, score)); + } + + final ruleset = localMatch.game.ruleset; + + if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) { + namedScores.sort((a, b) => b.$2.compareTo(a.$2)); + } else if (ruleset == Ruleset.lowestScore) { + namedScores.sort((a, b) => a.$2.compareTo(b.$2)); + } + } else { + final scores = localMatch.scores; + for (var player in localMatch.players) { + int score = scores[player.id]?.score ?? 0; + namedScores.add((player.name, score)); + } + + final ruleset = localMatch.game.ruleset; + + if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) { + namedScores.sort((a, b) => b.$2.compareTo(a.$2)); + } else if (ruleset == Ruleset.lowestScore) { + namedScores.sort((a, b) => a.$2.compareTo(b.$2)); + } + } + return namedScores; + } + + /// Returns the text widget for the score or placement value, styled according to the ruleset Widget getResultValueText(AppLocalizations loc, int index, int score) { - final ruleset = match.game.ruleset; + final ruleset = localMatch.game.ruleset; if (ruleset == Ruleset.placement) { return Text( @@ -410,9 +462,9 @@ class _MatchDetailViewState extends State { // Returns if the result can be displayed in a single row bool isSingleRowResult() { - return match.game.ruleset == Ruleset.singleWinner || - match.game.ruleset == Ruleset.singleLoser || - match.game.ruleset == Ruleset.multipleWinners; + return localMatch.game.ruleset == Ruleset.singleWinner || + localMatch.game.ruleset == Ruleset.singleLoser || + localMatch.game.ruleset == Ruleset.multipleWinners; } String getPlacementText(BuildContext context, int rank) { @@ -443,9 +495,19 @@ class _MatchDetailViewState extends State { } } - void updateScoresForCurrentMatch() { - db.scoreEntryDao - .getAllMatchScores(matchId: match.id) - .then((scores) => match.scores = scores); + Future updateScoresForCurrentMatch() async { + if (localMatch.isTeamMatch) { + final teams = await db.teamDao.getTeamsByMatchId(matchId: localMatch.id); + setState(() { + localMatch = localMatch.copyWith(teams: teams); + }); + } else { + final scores = await db.scoreEntryDao.getAllMatchScores( + matchId: localMatch.id, + ); + setState(() { + localMatch = localMatch.copyWith(scores: scores); + }); + } } } 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 fa51f0a..0c16191 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,18 +1,22 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; -import 'package:flutter/services.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/match.dart'; import 'package:tallee/data/models/player.dart'; import 'package:tallee/data/models/score_entry.dart'; +import 'package:tallee/data/models/team.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; -import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/match_result/live_edit_view.dart'; +import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart'; import 'package:tallee/presentation/widgets/tiles/match_result_view/custom_checkbox_list_tile.dart'; import 'package:tallee/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart'; -import 'package:tallee/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart'; import 'package:tallee/presentation/widgets/tiles/match_result_view/score_list_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_list_tile.dart'; @@ -35,12 +39,10 @@ class MatchResultView extends StatefulWidget { class _MatchResultViewState extends State { late final AppDatabase db; - bool isLiveEditMode = false; - late final Ruleset ruleset; - /// List of all players who participated in the match late final List allPlayers; + late final List allTeams; /// List of text controllers for score entry, one for each player late final List controller; @@ -48,19 +50,201 @@ class _MatchResultViewState extends State { /// Flag to indicate if the save button should be enabled late bool canSave; - /// Currently selected player (single winner / looser) - Player? _selectedPlayer; + late bool isTeamMatch; - /// Currently selected players (multiple winners) + /// Currently selected player(s)/team(s) (winner / looser) + Player? _selectedPlayer; + Team? _selectedTeam; final Set _selectedPlayers = {}; + final Set _selectedTeams = {}; @override void initState() { db = Provider.of(context, listen: false); ruleset = widget.match.game.ruleset; canSave = !rulesetSupportsScoreEntry(); + isTeamMatch = widget.match.isTeamMatch; - allPlayers = widget.match.players; + if (isTeamMatch) { + initializeAsTeamMatch(); + } else { + inizializeAsNormalMatch(); + } + + 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( + automaticallyImplyLeading: true, + leading: HapticIconButton( + icon: const Icon(Icons.close), + onPressed: () => { + widget.onWinnerChanged?.call(), + Navigator.pop(context), + }, + ), + title: Text(widget.match.name), + ), + body: Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorderColor), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + getTitleForRuleset(loc), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + + // Show player selection + if (rulesetSupportsPlayerSelection()) + if (ruleset == Ruleset.multipleWinners) + Expanded( + child: buildMultipleWinnerSelectionWidget(isTeamMatch), + ) + else + Expanded(child: buildPlayerSelectionWidget(isTeamMatch)), + + // Show score entry + if (rulesetSupportsScoreEntry()) + Expanded(child: buildScoreEntryWidget(isTeamMatch)), + + // Show draggable placement list + if (rulesetSupportsDragBehaviour()) + Expanded(child: buildPlacementWidget(isTeamMatch)), + ], + ), + ), + ), + + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Live Edit Mode Button + if (rulesetSupportsScoreEntry()) ...[ + AnimatedDialogButton( + buttonConstraints: const BoxConstraints( + minWidth: double.infinity, + minHeight: 50, + ), + buttonText: loc.live_edit_mode, + buttonType: ButtonType.secondary, + onPressed: () => + Navigator.push( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => + LiveEditView(match: widget.match), + ), + ).then( + (scores) => { + if (scores != null) + { + for (int i = 0; i < scores.length; i++) + {controller[i].text = scores[i].toString()}, + }, + }, + ), + ), + ], + + // Save Changes Button + AnimatedDialogButton( + buttonConstraints: const BoxConstraints( + minWidth: double.infinity, + minHeight: 50, + ), + buttonText: loc.save_changes, + onPressed: canSave + ? () async { + final ending = DateTime.now(); + await db.matchDao.updateMatchEndedAt( + matchId: widget.match.id, + endedAt: ending, + ); + await _handleSaving(); + if (!context.mounted) return; + Navigator.pop(context); + } + : null, + ), + ], + ), + ), + ], + ), + ); + } + + void initializeAsTeamMatch() { + allTeams = [...(widget.match.teams ?? [])]; + + controller = List.generate( + allTeams.length, + (index) => TextEditingController()..addListener(() => onTextEnter()), + ); + + // Prefill fields + if (widget.match.mvt.isNotEmpty) { + if (rulesetSupportsPlayerSelection()) { + if (ruleset == Ruleset.multipleWinners) { + for (int i = 0; i < allTeams.length; i++) { + if (allTeams[i].score == 1) { + _selectedTeams.add(allTeams[i]); + } + } + } else { + _selectedTeam = allTeams.firstWhere( + (team) => team.id == widget.match.mvt.first.id, + ); + } + } else if (rulesetSupportsScoreEntry()) { + for (int i = 0; i < allTeams.length; i++) { + final score = allTeams[i].score ?? 0; + controller[i].text = score.toString(); + } + } else if (rulesetSupportsDragBehaviour()) { + allTeams.sort((a, b) { + final scoreA = a.score ?? 0; + final scoreB = b.score ?? 0; + return scoreB.compareTo(scoreA); + }); + } + } + } + + void inizializeAsNormalMatch() { + allPlayers = [...widget.match.players]; allPlayers.sort((a, b) => a.name.compareTo(b.name)); controller = List.generate( @@ -95,325 +279,9 @@ class _MatchResultViewState extends State { return scoreB.compareTo(scoreA); }); } - 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( - leading: HapticIconButton( - icon: const Icon(Icons.close), - onPressed: () { - widget.onWinnerChanged?.call(); - Navigator.of(context).pop(_selectedPlayer); - }, - ), - title: Text(widget.match.name), - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: isLiveEditMode - // Live Edit Mode - ? ListView.builder( - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return LiveEditListTile( - title: allPlayers[index].name, - onChanged: (value) { - setState(() { - controller[index].text = value.toString(); - }); - }, - value: int.tryParse(controller[index].text) ?? 0, - ); - }, - ) - // Normal Container - : Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 10, - ), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorderColor), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - getTitleForRuleset(loc), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - - // Show player selection - if (rulesetSupportsPlayerSelection()) - Expanded( - child: ruleset == Ruleset.multipleWinners - // Multiple winners - ? ListView.builder( - physics: - const NeverScrollableScrollPhysics(), - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return CustomCheckboxListTile( - text: allPlayers[index].name, - value: _selectedPlayers.contains( - allPlayers[index], - ), - onChanged: (bool value) { - setState(() { - if (value) { - _selectedPlayers.add( - allPlayers[index], - ); - } else { - _selectedPlayers.remove( - allPlayers[index], - ); - } - }); - }, - ); - }, - ) - // Single winner / looser - : RadioGroup( - groupValue: _selectedPlayer, - onChanged: (Player? value) async { - await HapticFeedback.selectionClick(); - setState(() { - _selectedPlayer = value; - }); - }, - child: ListView.builder( - physics: - const NeverScrollableScrollPhysics(), - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return CustomRadioListTile( - text: allPlayers[index].name, - value: allPlayers[index], - onContainerTap: (value) async { - await HapticFeedback.selectionClick(); - 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); - } - }); - }, - ); - }, - ), - ), - ), - - // Show score entry - if (rulesetSupportsScoreEntry()) - Expanded( - child: ListView.separated( - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return ScoreListTile( - text: allPlayers[index].name, - controller: controller[index], - ); - }, - separatorBuilder: - (BuildContext context, int index) { - return const Padding( - padding: EdgeInsets.symmetric( - vertical: 8.0, - ), - child: Divider(indent: 20), - ); - }, - ), - ), - - // Show draggable placement list - if (rulesetSupportsDragBehaviour()) - Expanded( - child: Row( - children: [ - // Placement indicators - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Column( - children: [ - for ( - int i = 0; - i < allPlayers.length; - i++ - ) - Container( - alignment: Alignment.center, - height: 60, - child: Container( - decoration: BoxDecoration( - color: - CustomTheme.boxBorderColor, - borderRadius: CustomTheme - .standardBorderRadiusAll, - ), - alignment: Alignment.center, - height: 50, - width: 50, - child: Text( - ' #${i + 1} ', - style: const TextStyle( - color: CustomTheme.textColor, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - ), - ], - ), - ), - - // Drag list - Expanded( - child: ReorderableListView.builder( - physics: - const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - proxyDecorator: (child, index, animation) { - return AnimatedBuilder( - animation: animation, - child: child, - builder: (context, child) { - final alpha = - (Curves.easeInOut.transform( - animation.value, - ) * - 40) - .toInt(); - return Stack( - children: [ - child!, - Positioned.fill( - left: 4, - top: 4, - right: 4, - bottom: 4, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.white - .withAlpha(alpha), - borderRadius: CustomTheme - .standardBorderRadiusAll, - ), - ), - ), - ], - ); - }, - ); - }, - onReorderStart: (int n) async { - await HapticFeedback.selectionClick(); - }, - onReorderEnd: (int n) async { - await HapticFeedback.selectionClick(); - }, - onReorderItem: - (int oldIndex, int newIndex) { - setState(() { - if (newIndex > oldIndex) { - newIndex -= 1; - } - final Player item = allPlayers - .removeAt(oldIndex); - allPlayers.insert(newIndex, item); - }); - }, - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return TextIconListTile( - key: ValueKey(allPlayers[index].id), - text: allPlayers[index].name, - icon: Icons.drag_handle, - ); - }, - ), - ), - ], - ), - ), - ], - ), - ), - ), - - if (rulesetSupportsScoreEntry()) - // Button to switch to live edit mode - ...[ - CustomWidthButton( - text: isLiveEditMode ? loc.exit_view : loc.live_edit_mode, - sizeRelativeToWidth: 0.95, - buttonType: ButtonType.secondary, - onPressed: () => setState(() { - isLiveEditMode = !isLiveEditMode; - }), - ), - const SizedBox(height: 10), - ], - - // Save Changes Button - 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()) { @@ -444,62 +312,118 @@ class _MatchResultViewState extends State { /// Handles saving or removing the (single) winner in the database. Future _handleWinner() async { - if (_selectedPlayer == null) { - return await db.scoreEntryDao.removeWinner(matchId: widget.match.id); + if (isTeamMatch) { + if (_selectedTeam == null) { + return await db.teamDao.removeWinnerTeam(matchId: widget.match.id); + } else { + return await db.teamDao.setWinnerTeam( + matchId: widget.match.id, + teamId: _selectedTeam!.id, + ); + } } else { - return await db.scoreEntryDao.setWinner( - matchId: widget.match.id, - playerId: _selectedPlayer!.id, - ); + if (_selectedPlayer == null) { + return await db.scoreEntryDao.removeWinner(matchId: widget.match.id); + } else { + return await db.scoreEntryDao.setWinner( + matchId: widget.match.id, + playerId: _selectedPlayer!.id, + ); + } } } /// Handles saving the (multiple) winners to the database. Future _handleWinners() async { - if (_selectedPlayers.isEmpty) { - return await db.scoreEntryDao.removeWinner(matchId: widget.match.id); + if (isTeamMatch) { + if (_selectedTeams.isEmpty) { + return await db.teamDao.removeWinnerTeam(matchId: widget.match.id); + } else { + return await db.teamDao.setWinnerTeams( + matchId: widget.match.id, + + winners: _selectedTeams.toList(), + ); + } } else { - return await db.scoreEntryDao.setWinners( - matchId: widget.match.id, - winners: allPlayers.where((p) => _selectedPlayers.contains(p)).toList(), - ); + if (_selectedPlayers.isEmpty) { + return await db.scoreEntryDao.removeWinner(matchId: widget.match.id); + } else { + return await db.scoreEntryDao.setWinners( + matchId: widget.match.id, + winners: _selectedPlayers.toList(), + ); + } } } /// Handles saving or removing the loser in the database. Future _handleLoser() async { - if (_selectedPlayer == null) { - return await db.scoreEntryDao.removeLoser(matchId: widget.match.id); + if (isTeamMatch) { + if (_selectedTeam == null) { + return await db.teamDao.removeLoserTeam(matchId: widget.match.id); + } else { + return await db.teamDao.setLoserTeam( + matchId: widget.match.id, + teamId: _selectedTeam!.id, + ); + } } else { - return await db.scoreEntryDao.setLoser( - matchId: widget.match.id, - playerId: _selectedPlayer!.id, - ); + if (_selectedPlayer == null) { + return await db.scoreEntryDao.removeLoser(matchId: widget.match.id); + } else { + return await db.scoreEntryDao.setLoser( + 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'; + if (isTeamMatch) { + for (int i = 0; i < allTeams.length; i++) { + var text = controller[i].text; + if (text.isEmpty) { + text = '0'; + } + final score = int.parse(text); + await db.teamDao.updateTeamScore( + matchId: widget.match.id, + teamId: allTeams[i].id, + score: score, + ); + } + } else { + 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), + ); } - 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), - ); } } /// Handles saving the placement for each player in the database. Future _handlePlacement() async { - await db.scoreEntryDao.setPlacements( - matchId: widget.match.id, - players: allPlayers, - ); + if (isTeamMatch) { + await db.teamDao.setTeamPlacements( + matchId: widget.match.id, + teams: allTeams, + ); + } else { + await db.scoreEntryDao.setPlacements( + matchId: widget.match.id, + players: allPlayers, + ); + } } String getTitleForRuleset(AppLocalizations loc) { @@ -530,4 +454,387 @@ class _MatchResultViewState extends State { bool rulesetSupportsDragBehaviour() { return ruleset == Ruleset.placement; } + + Widget buildTeamTile({ + required Team team, + double? width, + int showingPlayerAmount = 3, + }) { + return Container( + width: width, + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 2), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: getColorFromGameColor(team.color).withAlpha(30), + border: Border.all(color: getColorFromGameColor(team.color), width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + team.name, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for ( + int i = 0; + i < min(team.members.length, showingPlayerAmount); + i++ + ) + Container( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 4, + ), + decoration: BoxDecoration( + color: CustomTheme.onBoxColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + team.members[i].name, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: 13, + color: CustomTheme.textColor.withAlpha(180), + ), + ), + ), + if (team.members.length > showingPlayerAmount) + Container( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 4, + ), + child: Text( + '+${team.members.length - showingPlayerAmount}', + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: 13, + color: CustomTheme.textColor.withAlpha(180), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget buildPlayerSelectionWidget(bool isTeamMatch) { + if (isTeamMatch) { + return RadioGroup( + groupValue: _selectedTeam, + onChanged: (Team? team) async { + setState(() { + _selectedTeam = team; + }); + }, + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: allTeams.length, + itemBuilder: (context, index) { + return CustomRadioListTile( + content: buildTeamTile(team: allTeams[index]), + value: allTeams[index], + onContainerTap: (team) async { + setState(() { + // Check if the already selected player is the same as the newly tapped player. + if (_selectedTeam == team) { + // If yes deselected the player by setting it to null. + _selectedTeam = null; + } else { + // If no assign the newly tapped player to the selected player. + (_selectedTeam = team); + } + }); + }, + ); + }, + ), + ); + } else { + return RadioGroup( + groupValue: _selectedPlayer, + onChanged: (Player? value) async { + setState(() { + _selectedPlayer = value; + }); + }, + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return CustomRadioListTile( + content: Text( + allPlayers[index].name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + 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); + } + }); + }, + ); + }, + ), + ); + } + } + + Widget buildScoreEntryWidget(bool isTeamMatch) { + if (isTeamMatch) { + return ListView.separated( + itemCount: allTeams.length, + itemBuilder: (context, index) { + return ScoreListTile( + content: buildTeamTile( + team: allTeams[index], + width: 220, + showingPlayerAmount: 2, + ), + horizontalPadding: 0, + controller: controller[index], + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Divider(indent: 20), + ); + }, + ); + } else { + return ListView.separated( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return ScoreListTile( + content: Text( + allPlayers[index].name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500), + ), + controller: controller[index], + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Divider(indent: 20), + ); + }, + ); + } + } + + Widget buildPlacementWidget(bool isTeamMatch) { + final placementCol = Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Column( + children: [ + for ( + int i = 0; + i < (isTeamMatch ? allTeams.length : allPlayers.length); + i++ + ) + Container( + alignment: Alignment.center, + height: 60, + child: Container( + decoration: BoxDecoration( + color: CustomTheme.boxBorderColor, + borderRadius: CustomTheme.standardBorderRadiusAll, + ), + alignment: Alignment.center, + height: 50, + width: 50, + child: Text( + ' #${i + 1} ', + style: const TextStyle( + color: CustomTheme.textColor, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ); + final valueCol = isTeamMatch + ? Expanded( + child: ReorderableListView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + proxyDecorator: (child, index, animation) { + return AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) { + final alpha = + (Curves.easeInOut.transform(animation.value) * 40) + .toInt(); + return Stack( + children: [ + child!, + Positioned.fill( + left: 4, + top: 4, + right: 4, + bottom: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withAlpha(alpha), + borderRadius: CustomTheme.standardBorderRadiusAll, + ), + ), + ), + ], + ); + }, + ); + }, + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final Team team = allTeams.removeAt(oldIndex); + allTeams.insert(newIndex, team); + }); + }, + itemCount: allTeams.length, + itemBuilder: (context, index) { + return TextIconListTile( + key: ValueKey(allTeams[index].id), + text: allTeams[index].name, + icon: Icons.drag_handle, + color: getColorFromGameColor(allTeams[index].color), + ); + }, + ), + ) + : Expanded( + child: ReorderableListView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + proxyDecorator: (child, index, animation) { + return AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) { + final alpha = + (Curves.easeInOut.transform(animation.value) * 40) + .toInt(); + return Stack( + children: [ + child!, + Positioned.fill( + left: 4, + top: 4, + right: 4, + bottom: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withAlpha(alpha), + borderRadius: CustomTheme.standardBorderRadiusAll, + ), + ), + ), + ], + ); + }, + ); + }, + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final Player item = allPlayers.removeAt(oldIndex); + allPlayers.insert(newIndex, item); + }); + }, + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return TextIconListTile( + key: ValueKey(allPlayers[index].id), + text: allPlayers[index].name, + icon: Icons.drag_handle, + ); + }, + ), + ); + + return Row(children: [placementCol, valueCol]); + } + + Widget buildMultipleWinnerSelectionWidget(bool isTeamMatch) { + if (isTeamMatch) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: allTeams.length, + itemBuilder: (context, index) { + return CustomCheckboxListTile( + content: buildTeamTile(team: allTeams[index]), + value: _selectedTeams.contains(allTeams[index]), + onChanged: (bool value) { + setState(() { + if (value) { + _selectedTeams.add(allTeams[index]); + } else { + _selectedTeams.remove(allTeams[index]); + } + }); + }, + ); + }, + ); + } else { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return CustomCheckboxListTile( + content: Text( + allPlayers[index].name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + value: _selectedPlayers.contains(allPlayers[index]), + onChanged: (bool value) { + setState(() { + if (value) { + _selectedPlayers.add(allPlayers[index]); + } else { + _selectedPlayers.remove(allPlayers[index]); + } + }); + }, + ); + }, + ); + } + } } 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 a7f60c6..dc94365 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -39,7 +39,7 @@ class _MatchViewState extends State { game: Game( name: 'Game name', ruleset: Ruleset.singleWinner, - color: GameColor.blue, + color: AppColor.blue, icon: '', ), group: Group( diff --git a/lib/presentation/widgets/buttons/animated_dialog_button.dart b/lib/presentation/widgets/buttons/animated_dialog_button.dart index 8c8765e..674856a 100644 --- a/lib/presentation/widgets/buttons/animated_dialog_button.dart +++ b/lib/presentation/widgets/buttons/animated_dialog_button.dart @@ -15,11 +15,12 @@ class AnimatedDialogButton extends StatefulWidget { this.buttonConstraints, this.buttonType = ButtonType.primary, this.isDescructive = false, + this.content, }); final String buttonText; - final VoidCallback onPressed; + final VoidCallback? onPressed; final BoxConstraints? buttonConstraints; @@ -27,6 +28,8 @@ class AnimatedDialogButton extends StatefulWidget { final bool isDescructive; + final Widget? content; + @override State createState() => _AnimatedDialogButtonState(); } @@ -38,28 +41,40 @@ class _AnimatedDialogButtonState extends State { Widget build(BuildContext context) { final textStyling = _getTextStyling(); final buttonDecoration = _getButtonDecoration(); + final isDisabled = widget.onPressed == null; - return GestureDetector( - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - onTap: widget.onPressed, - child: AnimatedScale( - scale: _isPressed ? 0.95 : 1.0, - duration: const Duration(milliseconds: 100), - child: AnimatedOpacity( - opacity: _isPressed ? 0.6 : 1.0, - duration: const Duration(milliseconds: 100), - 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, + return IgnorePointer( + ignoring: isDisabled, + child: Opacity( + opacity: isDisabled ? 0.4 : 1.0, + child: GestureDetector( + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + onTap: widget.onPressed, + child: AnimatedScale( + scale: _isPressed ? 0.95 : 1.0, + duration: const Duration(milliseconds: 100), + child: AnimatedOpacity( + opacity: _isPressed ? 0.6 : 1.0, + duration: const Duration(milliseconds: 100), + child: Center( + child: Container( + constraints: widget.buttonConstraints, + decoration: buttonDecoration, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + margin: const EdgeInsets.symmetric(vertical: 8), + child: widget.buttonText == '' + ? widget.content! + : Text( + widget.buttonText, + style: textStyling, + textAlign: TextAlign.center, + ), + ), ), ), ), diff --git a/lib/presentation/widgets/buttons/custom_width_button.dart b/lib/presentation/widgets/buttons/custom_width_button.dart index 556b784..46e7193 100644 --- a/lib/presentation/widgets/buttons/custom_width_button.dart +++ b/lib/presentation/widgets/buttons/custom_width_button.dart @@ -56,6 +56,7 @@ class CustomWidthButton extends StatelessWidget { onPressed!.call(); }, style: ElevatedButton.styleFrom( + splashFactory: NoSplash.splashFactory, foregroundColor: textcolor, disabledForegroundColor: disabledTextColor, backgroundColor: buttonBackgroundColor, @@ -91,6 +92,7 @@ class CustomWidthButton extends StatelessWidget { onPressed!.call(); }, style: OutlinedButton.styleFrom( + splashFactory: NoSplash.splashFactory, foregroundColor: textcolor, disabledForegroundColor: disabledTextColor, backgroundColor: buttonBackgroundColor, @@ -128,6 +130,7 @@ class CustomWidthButton extends StatelessWidget { onPressed!.call(); }, style: TextButton.styleFrom( + splashFactory: NoSplash.splashFactory, foregroundColor: textcolor, disabledForegroundColor: disabledTextColor, backgroundColor: buttonBackgroundColor, diff --git a/lib/presentation/widgets/buttons/main_menu_button.dart b/lib/presentation/widgets/buttons/main_menu_button.dart index c300eeb..984326b 100644 --- a/lib/presentation/widgets/buttons/main_menu_button.dart +++ b/lib/presentation/widgets/buttons/main_menu_button.dart @@ -17,7 +17,7 @@ class MainMenuButton extends StatefulWidget { }); /// The callback to be invoked when the button is pressed. - final void Function() onPressed; + final void Function()? onPressed; /// The icon of the button. final IconData icon; @@ -32,9 +32,11 @@ class MainMenuButton extends StatefulWidget { } class _MainMenuButtonState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late AnimationController _animationController; + late AnimationController _disabledAnimationController; late Animation _scaleAnimation; + late Animation _disabledScaleAnimation; /// How long the button needs to be pressed to register it as long press Timer? _longPressTimer; @@ -53,45 +55,67 @@ class _MainMenuButtonState extends State vsync: this, ); + _disabledAnimationController = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: this, + ); + _scaleAnimation = Tween(begin: 1.0, end: 0.95).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); + + _disabledScaleAnimation = Tween(begin: 1.0, end: 0.98).animate( + CurvedAnimation( + parent: _disabledAnimationController, + curve: Curves.easeInOut, + ), + ); } @override Widget build(BuildContext context) { return ScaleTransition( - scale: _scaleAnimation, + scale: widget.onPressed == null + ? _disabledScaleAnimation + : _scaleAnimation, child: GestureDetector( onTapDown: (_) { - _animationController.forward(); - if (widget.onLongPressed != null) { - _longPressTimer = Timer( - const Duration(milliseconds: 400), - () async { - _isLongPressing = true; - widget.onLongPressed?.call(); - await HapticFeedback.heavyImpact(); - _repeatTimer = Timer.periodic( - const Duration(milliseconds: 250), - (_) async { - widget.onLongPressed?.call(); - await HapticFeedback.heavyImpact(); - }, - ); - }, - ); + if (widget.onPressed == null) { + _disabledAnimationController.forward(); + } else { + _animationController.forward(); + if (widget.onLongPressed != null) { + _longPressTimer = Timer( + const Duration(milliseconds: 400), + () async { + _isLongPressing = true; + widget.onLongPressed?.call(); + await HapticFeedback.heavyImpact(); + _repeatTimer = Timer.periodic( + const Duration(milliseconds: 250), + (_) async { + widget.onLongPressed?.call(); + await HapticFeedback.heavyImpact(); + }, + ); + }, + ); + } } }, onTapUp: (_) async { - _cancelTimers(); - if (mounted && !_isLongPressing) { - await HapticFeedback.selectionClick(); - widget.onPressed(); + if (widget.onPressed == null) { + _disabledAnimationController.reverse(); + } else { + _cancelTimers(); + if (mounted && !_isLongPressing) { + await HapticFeedback.selectionClick(); + widget.onPressed?.call(); + } + _isLongPressing = false; + await Future.delayed(const Duration(milliseconds: 100)); + await _animationController.reverse(); } - _isLongPressing = false; - await Future.delayed(const Duration(milliseconds: 100)); - await _animationController.reverse(); }, onTapCancel: () { _isLongPressing = false; @@ -100,7 +124,7 @@ class _MainMenuButtonState extends State }, child: Container( decoration: BoxDecoration( - color: Colors.white, + color: widget.onPressed == null ? Colors.grey : Colors.white, borderRadius: BorderRadius.circular(30), ), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), @@ -131,6 +155,7 @@ class _MainMenuButtonState extends State void dispose() { _cancelTimers(); _animationController.dispose(); + _disabledAnimationController.dispose(); super.dispose(); } diff --git a/lib/presentation/widgets/cards/team_card.dart b/lib/presentation/widgets/cards/team_card.dart new file mode 100644 index 0000000..9121805 --- /dev/null +++ b/lib/presentation/widgets/cards/team_card.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/data/models/team.dart'; +import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; + +class TeamCard extends StatelessWidget { + const TeamCard({ + super.key, + required this.team, + this.compact = false, + this.width = double.infinity, + }); + + final Team team; + + final bool compact; + + final double width; + + @override + Widget build(BuildContext context) { + final teamColor = getColorFromGameColor(team.color); + + if (compact) { + return Container( + width: width, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: teamColor.withAlpha(50), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: teamColor, width: 2), + ), + child: Row( + children: [ + Expanded( + child: Text( + team.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + width: 1, + height: 14, + color: Colors.white.withValues(alpha: 0.35), + ), + const SizedBox(width: 8), + const Icon(Icons.people_alt_rounded, size: 14, color: Colors.white), + const SizedBox(width: 4), + Text( + '${team.members.length}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ); + } else { + return Container( + width: width, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: teamColor.withAlpha(50), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: teamColor, width: 2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + Text( + team.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: CustomTheme.textColor, + ), + ), + Wrap( + spacing: 6, + runSpacing: 6, + children: team.members.map((player) { + return TextIconTile( + text: player.name, + suffixText: getNameCountText(player), + ); + }).toList(), + ), + ], + ), + ); + } + } +} diff --git a/lib/presentation/widgets/game_label.dart b/lib/presentation/widgets/game_label.dart index 553e637..3eae9b1 100644 --- a/lib/presentation/widgets/game_label.dart +++ b/lib/presentation/widgets/game_label.dart @@ -12,7 +12,7 @@ class GameLabel extends StatelessWidget { final String title; final String description; - final GameColor color; + final AppColor color; @override Widget build(BuildContext context) { diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index 00d6c11..ec5ac15 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -143,9 +143,9 @@ class _PlayerSelectionState extends State { child: TextIconTile( text: player.name, suffixText: getNameCountText(player), - onIconTap: () { - setState(() async { - await HapticFeedback.selectionClick(); + onIconTap: () async { + await HapticFeedback.selectionClick(); + setState(() { // Removes the player from the selection and notifies the parent. selectedPlayers.remove(player); widget.onChanged([...selectedPlayers]); @@ -252,6 +252,9 @@ class _PlayerSelectionState extends State { ), ) .toList(); + suggestedPlayers = suggestedPlayers + .where((p) => !selectedPlayers.any((sp) => sp.id == p.id)) + .toList(); } } else { // Otherwise, use the loaded players from the database. diff --git a/lib/presentation/widgets/text_input/text_input_field.dart b/lib/presentation/widgets/text_input/text_input_field.dart index b074638..1d56508 100644 --- a/lib/presentation/widgets/text_input/text_input_field.dart +++ b/lib/presentation/widgets/text_input/text_input_field.dart @@ -57,7 +57,6 @@ class TextInputField extends StatelessWidget { filled: true, fillColor: CustomTheme.boxColor, hintText: hintText, - hintStyle: const TextStyle(fontSize: 18), counterText: showCounterText ? null : '', enabledBorder: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(12)), diff --git a/lib/presentation/widgets/tiles/game_tile.dart b/lib/presentation/widgets/tiles/game_tile.dart index ee5acf0..2417408 100644 --- a/lib/presentation/widgets/tiles/game_tile.dart +++ b/lib/presentation/widgets/tiles/game_tile.dart @@ -7,6 +7,7 @@ import 'package:tallee/core/enums.dart'; class GameTile extends StatelessWidget { /// A list tile widget that displays a title and description, with optional highlighting and badge. /// - [title]: The title text displayed on the tile. + /// - [subtitle]: An optional subtitle displayed under the title. /// - [description]: The description text displayed below the title. /// - [onTap]: The callback invoked when the tile is tapped. /// - [onLongPress]: The callback invoked when the tile is tapped. @@ -17,6 +18,7 @@ class GameTile extends StatelessWidget { super.key, required this.title, required this.description, + this.subtitle, this.onTap, this.onLongPress, this.isHighlighted = false, @@ -24,25 +26,20 @@ class GameTile extends StatelessWidget { this.badgeColor, }); - /// The title text displayed on the tile. final String title; - /// The description text displayed below the title. + final String? subtitle; + final String description; - /// The callback invoked when the tile is tapped. final VoidCallback? onTap; - /// The callback invoked when the tile is long-pressed. final VoidCallback? onLongPress; - /// A boolean to determine if the tile should be highlighted. final bool isHighlighted; - /// Optional text to display in a badge on the right side of the title. final String? badgeText; - /// Optional color for the badge background. final Color? badgeColor; @override @@ -51,7 +48,7 @@ class GameTile extends StatelessWidget { ? (badgeColor!.computeLuminance() > 0.5 ? Colors.black : Colors.white) : Colors.white; - final gameColor = badgeColor ?? getColorFromGameColor(GameColor.orange); + final gameColor = badgeColor ?? getColorFromGameColor(AppColor.orange); return GestureDetector( onTap: () async { @@ -67,13 +64,14 @@ class GameTile extends StatelessWidget { } }, child: AnimatedContainer( - margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), decoration: !isHighlighted ? CustomTheme.standardBoxDecoration : CustomTheme.highlightedBoxDecoration.copyWith( border: Border.all( color: gameColor.withValues(alpha: 0.9), width: 2, + strokeAlign: BorderSide.strokeAlignCenter, ), ), duration: const Duration(milliseconds: 200), @@ -118,6 +116,21 @@ class GameTile extends StatelessWidget { ), ), + // Title + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.hintColor, + ), + ), + ], + // Badge if (badgeText != null) ...[ const SizedBox(height: 5), diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index f6c406e..7744296 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -91,7 +91,6 @@ class _GroupTileState extends State { TextIconTile( text: member.name, suffixText: getNameCountText(member), - iconEnabled: false, ), ], ), diff --git a/lib/presentation/widgets/tiles/match_result_view/custom_checkbox_list_tile.dart b/lib/presentation/widgets/tiles/match_result_view/custom_checkbox_list_tile.dart index bb6c933..23d7be6 100644 --- a/lib/presentation/widgets/tiles/match_result_view/custom_checkbox_list_tile.dart +++ b/lib/presentation/widgets/tiles/match_result_view/custom_checkbox_list_tile.dart @@ -5,12 +5,12 @@ import 'package:tallee/core/custom_theme.dart'; class CustomCheckboxListTile extends StatelessWidget { const CustomCheckboxListTile({ super.key, - required this.text, + required this.content, required this.value, required this.onChanged, }); - final String text; + final Widget content; final bool value; final ValueChanged onChanged; @@ -39,16 +39,7 @@ class CustomCheckboxListTile extends StatelessWidget { onChanged(v); }, ), - Expanded( - child: Text( - text, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), + Expanded(child: content), ], ), ), diff --git a/lib/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart b/lib/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart index 5559d10..1016a14 100644 --- a/lib/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart +++ b/lib/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart @@ -8,13 +8,13 @@ class CustomRadioListTile extends StatelessWidget { /// - [onContainerTap]: The callback invoked when the container is tapped. const CustomRadioListTile({ super.key, - required this.text, + required this.content, required this.value, required this.onContainerTap, }); /// The text to display next to the radio button. - final String text; + final Widget content; /// The value associated with the radio button. final T value; @@ -37,16 +37,7 @@ class CustomRadioListTile extends StatelessWidget { child: Row( children: [ Radio(value: value, toggleable: true), - Expanded( - child: Text( - text, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), + Expanded(child: content), ], ), ), diff --git a/lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart b/lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart index e4cfff9..e2970b1 100644 --- a/lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart +++ b/lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart @@ -5,36 +5,34 @@ 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. + /// - [content]: The leading Widget to be displayed. /// - [controller]: The controller for the text field to input the score. const ScoreListTile({ super.key, - required this.text, + required this.content, required this.controller, + this.horizontalPadding = 20, }); - /// The text to display next to the radio button. - final String text; + final Widget content; final TextEditingController controller; + final double horizontalPadding; + @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), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), 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), - ), + content, SizedBox( width: 100, height: 40, diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index 6a81dc3..b3d326b 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -8,6 +8,7 @@ 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/cards/team_card.dart'; import 'package:tallee/presentation/widgets/game_label.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -17,13 +18,11 @@ class MatchTile extends StatefulWidget { /// - [match]: The match data to be displayed. /// - [onTap]: The callback invoked when the tile is tapped. /// - [width]: Optional width for the tile. - /// - [compact]: Whether to display the tile in a compact mode const MatchTile({ super.key, required this.match, required this.onTap, this.width, - this.compact = false, }); /// The match data to be displayed. @@ -35,9 +34,6 @@ class MatchTile extends StatefulWidget { /// Optional width for the tile. final double? width; - /// Whether to display the tile in a compact mode - final bool compact; - @override State createState() => _MatchTileState(); } @@ -100,40 +96,59 @@ class _MatchTileState extends State { ], ), const SizedBox(height: 4), - ] else if (widget.compact) ...[ - Row( - children: [ - const Icon(Icons.person, size: 16, color: Colors.grey), - const SizedBox(width: 6), - Expanded( - child: Text( - '${match.players.length} ${loc.players}', - style: const TextStyle(fontSize: 14, color: Colors.grey), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 6), ] else ...[ const SizedBox(height: 8), ], // Game + Ruleset Badge - if (!widget.compact) - GameLabel( - title: match.game.name, - description: translateRulesetToString( - match.game.ruleset, - context, - ), - color: match.game.color, + GameLabel( + title: match.game.name, + description: translateRulesetToString( + match.game.ruleset, + context, ), + color: match.game.color, + ), const SizedBox(height: 12), // Winner / In Progress Info - if (match.mvp.isNotEmpty) ...[ + if (match.isTeamMatch && match.mvt.isNotEmpty) ...[ + // MVT Display for team matches + Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 12, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.green.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + getMvpIcon(), + const SizedBox(width: 8), + Expanded( + child: Text( + getMvtText(loc), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: CustomTheme.textColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ] else if (match.mvp.isNotEmpty) ...[ + // MVP Display for player matches Container( padding: const EdgeInsets.symmetric( vertical: 8, @@ -167,6 +182,7 @@ class _MatchTileState extends State { ), const SizedBox(height: 12), ] else ...[ + // Match in progress display Container( padding: const EdgeInsets.symmetric( vertical: 8, @@ -205,8 +221,46 @@ class _MatchTileState extends State { const SizedBox(height: 12), ], - // Players List - if (players.isNotEmpty && widget.compact == false) ...[ + if (match.teams != null && + match.teams!.isNotEmpty && + match.isTeamMatch) ...[ + // Team display + Text( + loc.teams, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + final useSingleColumn = match.teams!.any( + (team) => team.name.length > 10, + ); + + const spacing = 8.0; + final itemWidth = useSingleColumn + ? constraints.maxWidth + : (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: match.teams!.map((team) { + return TeamCard( + team: team, + compact: true, + width: itemWidth, + ); + }).toList(), + ); + }, + ), + const SizedBox(height: 12), + ] else if (players.isNotEmpty) ...[ + // Player display Text( loc.players, style: const TextStyle( @@ -223,10 +277,17 @@ class _MatchTileState extends State { return TextIconTile( text: player.name, suffixText: getNameCountText(player), - iconEnabled: false, ); }).toList(), ), + ] else ...[ + Text( + loc.no_players_available, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.hintColor, + ), + ), ], ], ), @@ -252,6 +313,7 @@ class _MatchTileState extends State { } } + // Returns the appropriate text based on the match's ruleset and MVP. String getMvpText(AppLocalizations loc) { if (widget.match.mvp.isEmpty) return ''; final ruleset = widget.match.game.ruleset; @@ -275,11 +337,41 @@ class _MatchTileState extends State { return '${loc.winner}: n.A.'; } + // Returns the appropriate text based on the match's ruleset and MVT. + String getMvtText(AppLocalizations loc) { + if (widget.match.mvt.isEmpty) return ''; + final ruleset = widget.match.game.ruleset; + + switch (ruleset) { + case Ruleset.singleWinner: + return '${loc.winner}: ${widget.match.mvt.first.name}'; + case Ruleset.singleLoser: + return '${loc.loser}: ${widget.match.mvt.first.name}'; + case Ruleset.highestScore: + case Ruleset.lowestScore: + final mvt = widget.match.mvt; + final mvtScore = + widget.match.teams! + .firstWhere((team) => team.id == mvt.first.id) + .score ?? + 0; + final mvtNames = mvt.map((team) => team.name).join(', '); + return '${loc.winner}: $mvtNames (${getPointLabel(loc, mvtScore)})'; + case Ruleset.placement: + return '${loc.winner}: ${widget.match.mvt.first.name}'; + case Ruleset.multipleWinners: + final mvtNames = widget.match.mvt.map((team) => team.name).join(', '); + return '${loc.winners}: $mvtNames'; + } + } + + // Returns the appropriate icon based on the match's ruleset. Icon getMvpIcon() { final icon = getRulesetIcon(widget.match.game.ruleset); switch (widget.match.game.ruleset) { case Ruleset.singleWinner: + case Ruleset.multipleWinners: return Icon(icon, size: 20, color: Colors.amber); case Ruleset.singleLoser: return Icon(icon, size: 20, color: Colors.blue); @@ -287,8 +379,6 @@ class _MatchTileState extends State { return Icon(icon, size: 20, color: Colors.orange); case Ruleset.highestScore: return Icon(icon, size: 20, color: Colors.green); - case Ruleset.multipleWinners: - return Icon(icon, size: 20, color: Colors.amber); case Ruleset.placement: return Icon(icon, size: 20, color: Colors.deepOrangeAccent); } diff --git a/lib/presentation/widgets/tiles/team_creation_tile.dart b/lib/presentation/widgets/tiles/team_creation_tile.dart new file mode 100644 index 0000000..c6d999b --- /dev/null +++ b/lib/presentation/widgets/tiles/team_creation_tile.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:fluttericon/font_awesome_icons.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/constants.dart'; +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/widgets/buttons/haptic_icon_button.dart'; +import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; + +class TeamCreationTile extends StatefulWidget { + const TeamCreationTile({ + super.key, + required this.color, + required this.controller, + required this.hintText, + this.onDelete, + this.onColorSelection, + }); + + final AppColor color; + + final TextEditingController controller; + + final String hintText; + + final VoidCallback? onDelete; + + final ValueChanged? onColorSelection; + + @override + State createState() => _TeamCreationTileState(); +} + +class _TeamCreationTileState extends State { + final teamColors = List.generate( + AppColor.values.length, + (index) => getTeamColor(index), + ); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Container( + margin: CustomTheme.standardMargin, + decoration: CustomTheme.standardBoxDecoration, + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name input + delete icon + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: TextInputField( + controller: widget.controller, + hintText: widget.hintText, + maxLength: Constants.MAX_TEAM_NAME_LENGTH, + ), + ), + HapticIconButton( + icon: const Icon(FontAwesome.trash), + color: CustomTheme.textColor, + iconSize: 25, + onPressed: widget.onDelete, + ), + ], + ), + const SizedBox(height: 12), + + // Color label + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + loc.color, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor, + ), + ), + ), + const SizedBox(height: 8), + + // Color picker + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: teamColors.map((color) { + final isSelected = widget.color == color; + return GestureDetector( + onTap: () { + widget.onColorSelection?.call(color); + }, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: getColorFromGameColor(color), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Colors.white + : Colors.transparent, + width: 3, + ), + ), + child: isSelected + ? const Icon( + Icons.check, + size: 18, + color: Colors.white, + ) + : null, + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/text_icon_list_tile.dart b/lib/presentation/widgets/tiles/text_icon_list_tile.dart index 04a0803..7684f75 100644 --- a/lib/presentation/widgets/tiles/text_icon_list_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_list_tile.dart @@ -11,6 +11,7 @@ class TextIconListTile extends StatelessWidget { required this.text, this.suffixText = '', this.icon, + this.color, this.onPressed, }); @@ -23,6 +24,8 @@ class TextIconListTile extends StatelessWidget { /// The icon to display in the tile. final IconData? icon; + final Color? color; + /// The callback to be invoked when the icon is pressed. final VoidCallback? onPressed; @@ -31,7 +34,17 @@ class TextIconListTile extends StatelessWidget { return Container( margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 15), - decoration: CustomTheme.standardBoxDecoration, + decoration: BoxDecoration( + color: + Color.lerp(CustomTheme.onBoxColor, color?.withAlpha(10), 0.1) ?? + CustomTheme.boxColor, + border: Border.all( + color: color ?? CustomTheme.boxBorderColor, + width: color != null ? 2 : 1, + strokeAlign: BorderSide.strokeAlignCenter, + ), + borderRadius: CustomTheme.standardBorderRadiusAll, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, diff --git a/lib/presentation/widgets/tiles/text_icon_tile.dart b/lib/presentation/widgets/tiles/text_icon_tile.dart index 541b6ae..499cbbb 100644 --- a/lib/presentation/widgets/tiles/text_icon_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_tile.dart @@ -4,14 +4,14 @@ import 'package:tallee/core/custom_theme.dart'; class TextIconTile extends StatelessWidget { /// A tile widget that displays text with an optional icon that can be tapped. /// - [text]: The text to display in the tile. - /// - [iconEnabled]: A boolean to determine if the icon should be displayed. /// - [onIconTap]: The callback to be invoked when the icon is tapped. + /// - [icon]: Optional custom icon. Defaults to [Icons.close]. const TextIconTile({ super.key, required this.text, this.suffixText = '', - this.iconEnabled = true, this.onIconTap, + this.icon = Icons.close, }); /// The text to display in the tile. @@ -19,14 +19,16 @@ class TextIconTile extends StatelessWidget { final String suffixText; - /// A boolean to determine if the icon should be displayed. - final bool iconEnabled; - /// The callback to be invoked when the icon is tapped. final VoidCallback? onIconTap; + /// The icon to display. Defaults to [Icons.close]. + final IconData icon; + @override Widget build(BuildContext context) { + final iconEnabled = onIconTap != null; + return Container( padding: const EdgeInsets.all(5), decoration: BoxDecoration( @@ -65,10 +67,7 @@ class TextIconTile extends StatelessWidget { ), if (iconEnabled) ...[ const SizedBox(width: 3), - GestureDetector( - onTap: onIconTap, - child: const Icon(Icons.close, size: 20), - ), + GestureDetector(onTap: onIconTap, child: Icon(icon, size: 20)), ], ], ), diff --git a/lib/services/data_transfer_service.dart b/lib/services/data_transfer_service.dart index 29199f8..6bc5cda 100644 --- a/lib/services/data_transfer_service.dart +++ b/lib/services/data_transfer_service.dart @@ -200,13 +200,9 @@ class DataTransferService { .map((id) => playerById[id]) .whereType() .toList(); + final team = Team.fromJson(map); - return Team( - id: map['id'] as String, - name: map['name'] as String, - members: members, - createdAt: DateTime.parse(map['createdAt'] as String), - ); + return team.copyWith(members: members); }).toList(); } @@ -231,6 +227,7 @@ class DataTransferService { final endedAt = map['endedAt'] != null ? DateTime.parse(map['endedAt'] as String) : null; + final isTeamMatch = map['isTeamMatch'] as bool; final notes = map['notes'] as String? ?? ''; final scoresJson = map['scores'] as Map? ?? {}; final scores = scoresJson.map( @@ -262,6 +259,7 @@ class DataTransferService { game: game, group: group, players: players, + isTeamMatch: isTeamMatch, teams: teams.isEmpty ? null : teams, createdAt: createdAt, endedAt: endedAt, @@ -278,7 +276,7 @@ class DataTransferService { name: 'Unknown', ruleset: Ruleset.singleWinner, description: '', - color: GameColor.blue, + color: AppColor.blue, icon: '', ); } diff --git a/pubspec.yaml b/pubspec.yaml index 3d8d99f..b93556c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: tallee description: "Tracking App for Card Games" publish_to: 'none' -version: 0.0.33+273 +version: 0.0.33+340 environment: sdk: ^3.8.1 diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index 37c1cd0..3f76a23 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -56,7 +56,7 @@ void main() { name: 'Test Game', ruleset: Ruleset.singleWinner, description: 'A test game', - color: GameColor.blue, + color: AppColor.blue, icon: '', ); testMatch1 = Match( @@ -507,34 +507,36 @@ void main() { deleted = await database.matchDao.deleteAllMatches(); expect(deleted, isFalse); }); - }); - test('deleteMatchesByGame() deletes all matches for a game', () async { - await database.matchDao.addMatch(match: testMatch1); - await database.matchDao.addMatch(match: testMatch2); + test('deleteMatchesByGame() deletes all matches for a game', () async { + await database.matchDao.addMatch(match: testMatch1); + await database.matchDao.addMatch(match: testMatch2); - var count = await database.matchDao.getMatchCountByGame( - gameId: testGame.id, - ); - expect(count, 2); + var count = await database.matchDao.getMatchCountByGame( + gameId: testGame.id, + ); + expect(count, 2); - final deletedCount = await database.matchDao.deleteMatchesByGame( - gameId: testGame.id, - ); - expect(deletedCount, 2); + final deletedCount = await database.matchDao.deleteMatchesByGame( + gameId: testGame.id, + ); + expect(deletedCount, 2); - count = await database.matchDao.getMatchCountByGame(gameId: testGame.id); - expect(count, 0); + count = await database.matchDao.getMatchCountByGame( + gameId: testGame.id, + ); + expect(count, 0); - final allMatches = await database.matchDao.getAllMatches(); - expect(allMatches, isEmpty); - }); + final allMatches = await database.matchDao.getAllMatches(); + expect(allMatches, isEmpty); + }); - test('deleteMatchesByGame() returns 0 for non-existent game', () async { - final deletedCount = await database.matchDao.deleteMatchesByGame( - gameId: 'non-existent-game-id', - ); - expect(deletedCount, 0); + test('deleteMatchesByGame() returns 0 for non-existent game', () async { + final deletedCount = await database.matchDao.deleteMatchesByGame( + gameId: 'non-existent-game-id', + ); + expect(deletedCount, 0); + }); }); }); } diff --git a/test/db_tests/aggregates/team_test.dart b/test/db_tests/aggregates/team_test.dart index fefdcc5..832dcb8 100644 --- a/test/db_tests/aggregates/team_test.dart +++ b/test/db_tests/aggregates/team_test.dart @@ -1,7 +1,7 @@ import 'dart:core' hide Match; import 'package:clock/clock.dart'; -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' hide isNotNull, isNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tallee/core/enums.dart'; @@ -49,7 +49,7 @@ void main() { testGame = Game( name: 'Test Game', ruleset: Ruleset.highestScore, - color: GameColor.blue, + color: AppColor.blue, icon: '', ); testMatch1 = Match( @@ -327,5 +327,200 @@ void main() { expect(deleted, isFalse); }); }); + + group('SCORE', () { + test('updateTeamScore() works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + final updated = await database.teamDao.updateTeamScore( + teamId: testTeam1.id, + matchId: testMatch1.id, + score: 5, + ); + expect(updated, isTrue); + final team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, 5); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 5); + } + }); + + test('set-/removeWinnerTeam() works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + final set = await database.teamDao.setWinnerTeam( + teamId: testTeam1.id, + matchId: testMatch1.id, + ); + expect(set, isTrue); + + var team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, 1); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 1); + } + + final removed = await database.teamDao.removeWinnerTeam( + matchId: testMatch1.id, + ); + expect(removed, isTrue); + + team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, isNull); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNull); + } + }); + + test('set-/removeLoserTeam() works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + final set = await database.teamDao.setLoserTeam( + teamId: testTeam1.id, + matchId: testMatch1.id, + ); + expect(set, isTrue); + + var team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, 0); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 0); + } + + final removed = await database.teamDao.removeLoserTeam( + matchId: testMatch1.id, + ); + expect(removed, isTrue); + + team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, isNull); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNull); + } + }); + + test('set-/removeWinnerTeams() works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + final set = await database.teamDao.setWinnerTeams( + winners: [testTeam1, testTeam2], + matchId: testMatch1.id, + ); + expect(set, isTrue); + + // check both teams got the winner score + var team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, 1); + team = await database.teamDao.getTeamById(teamId: testTeam2.id); + expect(team.score, 1); + + // check all members of both teams got the winner score + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 1); + } + + for (final member in testTeam2.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 1); + } + + final removed = await database.teamDao.removeWinnerTeam( + matchId: testMatch1.id, + ); + expect(removed, isTrue); + + team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, isNull); + + team = await database.teamDao.getTeamById(teamId: testTeam2.id); + expect(team.score, isNull); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNull); + } + + for (final member in testTeam2.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNull); + } + }); + + test('setTeamPlacements() works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + final set = await database.teamDao.setTeamPlacements( + teams: [testTeam1, testTeam2], + matchId: testMatch1.id, + ); + expect(set, isTrue); + + var team = await database.teamDao.getTeamById(teamId: testTeam1.id); + expect(team.score, 2); + team = await database.teamDao.getTeamById(teamId: testTeam2.id); + expect(team.score, 1); + + for (final member in testTeam1.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 2); + } + + for (final member in testTeam2.members) { + final entry = await database.scoreEntryDao.getScore( + playerId: member.id, + matchId: testMatch1.id, + ); + expect(entry, isNotNull); + expect(entry!.score, 1); + } + }); + }); }); } diff --git a/test/db_tests/entities/game_test.dart b/test/db_tests/entities/game_test.dart index 778d43b..f7e7dcd 100644 --- a/test/db_tests/entities/game_test.dart +++ b/test/db_tests/entities/game_test.dart @@ -28,7 +28,7 @@ void main() { name: 'Chess', ruleset: Ruleset.singleWinner, description: 'A classic strategy game', - color: GameColor.blue, + color: AppColor.blue, icon: 'chess_icon', ); testGame2 = Game( @@ -36,7 +36,7 @@ void main() { name: 'Poker', ruleset: Ruleset.multipleWinners, description: 'Card game with multiple winners', - color: GameColor.red, + color: AppColor.red, icon: 'poker_icon', ); testGame3 = Game( @@ -44,7 +44,7 @@ void main() { name: 'Monopoly', ruleset: Ruleset.highestScore, description: 'A board game about real estate', - color: GameColor.orange, + color: AppColor.orange, icon: '', ); }); @@ -124,7 +124,7 @@ void main() { name: 'Game\'s & "Special" ', ruleset: Ruleset.multipleWinners, description: 'Description with émojis 🎮🎲', - color: GameColor.purple, + color: AppColor.purple, icon: '', ); await database.gameDao.addGame(game: specialGame); @@ -280,19 +280,19 @@ void main() { await database.gameDao.updateGameColor( gameId: testGame1.id, - color: GameColor.green, + color: AppColor.green, ); final updatedGame = await database.gameDao.getGameById( gameId: testGame1.id, ); - expect(updatedGame.color, GameColor.green); + expect(updatedGame.color, AppColor.green); }); test('updateGameColor() does nothing for non-existent game', () async { final updated = await database.gameDao.updateGameColor( gameId: 'non-existent-id', - color: GameColor.green, + color: AppColor.green, ); expect(updated, isFalse); @@ -336,7 +336,7 @@ void main() { name: newName, ); - const newGameColor = GameColor.teal; + const newGameColor = AppColor.teal; await database.gameDao.updateGameColor( gameId: testGame1.id, color: newGameColor, diff --git a/test/db_tests/relationships/player_match_test.dart b/test/db_tests/relationships/player_match_test.dart index 6d879c3..fa7ec21 100644 --- a/test/db_tests/relationships/player_match_test.dart +++ b/test/db_tests/relationships/player_match_test.dart @@ -42,7 +42,7 @@ void main() { name: 'Test Game', ruleset: Ruleset.singleWinner, description: 'A test game', - color: GameColor.blue, + color: AppColor.blue, icon: '', ); testMatch1 = Match( diff --git a/test/db_tests/values/score_entry_test.dart b/test/db_tests/values/score_entry_test.dart index f6cc292..593d194 100644 --- a/test/db_tests/values/score_entry_test.dart +++ b/test/db_tests/values/score_entry_test.dart @@ -40,7 +40,7 @@ void main() { name: 'Test Game', ruleset: Ruleset.singleWinner, description: 'A test game', - color: GameColor.blue, + color: AppColor.blue, icon: '', ); testMatch1 = Match( diff --git a/test/services/data_transfer_service_test.dart b/test/services/data_transfer_service_test.dart index 586138a..0ea8b83 100644 --- a/test/services/data_transfer_service_test.dart +++ b/test/services/data_transfer_service_test.dart @@ -45,7 +45,7 @@ void main() { name: 'Chess', ruleset: Ruleset.singleWinner, description: 'Strategic board game', - color: GameColor.blue, + color: AppColor.blue, icon: 'chess_icon', ); @@ -55,7 +55,12 @@ void main() { members: [testPlayer1, testPlayer2], ); - testTeam = Team(name: 'Test Team', members: [testPlayer1, testPlayer2]); + testTeam = Team( + name: 'Test Team', + color: AppColor.yellow, + score: 5, + members: [testPlayer1, testPlayer2], + ); testMatch = Match( name: 'Test Match', @@ -137,9 +142,6 @@ void main() { await database.playerDao.addPlayer(player: testPlayer2); await database.gameDao.addGame(game: testGame); await database.groupDao.addGroup(group: testGroup); - /* - await database.teamDao.addTeam(team: testTeam); -*/ await database.matchDao.addMatch(match: testMatch); final ctx = await getContext(tester); @@ -448,19 +450,19 @@ void main() { Game( name: 'Red Game', ruleset: Ruleset.singleWinner, - color: GameColor.red, + color: AppColor.red, icon: 'icon', ), Game( name: 'Blue Game', ruleset: Ruleset.singleWinner, - color: GameColor.blue, + color: AppColor.blue, icon: 'icon', ), Game( name: 'Green Game', ruleset: Ruleset.singleWinner, - color: GameColor.green, + color: AppColor.green, icon: 'icon', ), ]; @@ -484,19 +486,19 @@ void main() { Game( name: 'Highest Score Game', ruleset: Ruleset.highestScore, - color: GameColor.blue, + color: AppColor.blue, icon: 'icon', ), Game( name: 'Lowest Score Game', ruleset: Ruleset.lowestScore, - color: GameColor.blue, + color: AppColor.blue, icon: 'icon', ), Game( name: 'Single Winner', ruleset: Ruleset.singleWinner, - color: GameColor.blue, + color: AppColor.blue, icon: 'icon', ), ]; @@ -669,6 +671,8 @@ void main() { 'name': testTeam.name, 'memberIds': [testPlayer1.id], 'createdAt': testTeam.createdAt.toIso8601String(), + 'color': testTeam.color.name, + 'score': testTeam.score, }, ]; @@ -682,6 +686,8 @@ void main() { expect(teams[0].name, testTeam.name); expect(teams[0].members.length, 1); expect(teams[0].members[0].id, testPlayer1.id); + expect(teams[0].color, testTeam.color); + expect(teams[0].score, testTeam.score); }); test('parseTeamsFromJson() empty list', () { @@ -718,6 +724,9 @@ void main() { 'gameId': testGame.id, 'groupId': testGroup.id, 'playerIds': [testPlayer1.id, testPlayer2.id], + 'isTeamMatch': false, + 'teams': null, + 'scores': null, 'notes': testMatch.notes, 'createdAt': testMatch.createdAt.toIso8601String(), }, @@ -773,6 +782,9 @@ void main() { 'name': testMatch.name, 'gameId': 'non-existent-game-id', 'playerIds': [testPlayer1.id], + 'isTeamMatch': false, + 'teams': null, + 'scores': null, 'notes': '', 'createdAt': testMatch.createdAt.toIso8601String(), }, @@ -804,6 +816,9 @@ void main() { 'gameId': testGame.id, 'groupId': null, 'playerIds': [testPlayer1.id], + 'isTeamMatch': false, + 'teams': null, + 'scores': null, 'notes': '', 'createdAt': testMatch.createdAt.toIso8601String(), }, @@ -834,6 +849,9 @@ void main() { 'name': testMatch.name, 'gameId': testGame.id, 'playerIds': [testPlayer1.id], + 'isTeamMatch': false, + 'teams': null, + 'scores': null, 'notes': '', 'createdAt': testMatch.createdAt.toIso8601String(), 'endedAt': endedDate.toIso8601String(), @@ -853,7 +871,7 @@ void main() { }); }); - test('validateJsonSchema()', () async { + test('validateJsonSchema() works correctly', () async { final validJson = json.encode({ 'players': [ { @@ -897,6 +915,15 @@ void main() { }, 'createdAt': testMatch.createdAt.toIso8601String(), 'endedAt': null, + 'isTeamMatch': true, + 'teams': [ + { + 'id': testTeam.id, + 'name': testTeam.name, + 'memberIds': [testPlayer1.id, testPlayer2.id], + 'createdAt': testTeam.createdAt.toIso8601String(), + }, + ], }, ], }); @@ -904,5 +931,28 @@ void main() { final isValid = await DataTransferService.validateJsonSchema(validJson); expect(isValid, true); }); + + testWidgets('validateJsonSchema() validates exported json file', ( + tester, + ) async { + await database.playerDao.addPlayer(player: testPlayer1); + await database.playerDao.addPlayer(player: testPlayer2); + await database.gameDao.addGame(game: testGame); + await database.groupDao.addGroup(group: testGroup); + await database.matchDao.addMatch(match: testMatch); + + final ctx = await getContext(tester); + final jsonString = await DataTransferService.getAppDataAsJson(ctx); + + expect(jsonString, isNotEmpty); + + // Schema validation requires real async operations (rootBundle, + // HttpClient within json_schema). These must run via + // tester.runAsync, otherwise the test hangs due to a pending timer. + final isValid = await tester.runAsync( + () => DataTransferService.validateJsonSchema(jsonString), + ); + expect(isValid, true); + }); }); }