diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7e79382..b320936 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -31,6 +31,27 @@ LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/core/common.dart b/lib/core/common.dart new file mode 100644 index 0000000..a27daf0 --- /dev/null +++ b/lib/core/common.dart @@ -0,0 +1,45 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tallee/core/enums.dart'; +import 'package:tallee/data/dto/match.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; + +/// Translates a [Ruleset] enum value to its corresponding localized string. +String translateRulesetToString(Ruleset ruleset, BuildContext context) { + final loc = AppLocalizations.of(context); + switch (ruleset) { + case Ruleset.highestScore: + return loc.highest_score; + case Ruleset.lowestScore: + return loc.lowest_score; + case Ruleset.singleWinner: + return loc.single_winner; + case Ruleset.singleLoser: + return loc.single_loser; + case Ruleset.multipleWinners: + return loc.multiple_winners; + } +} + +/// Counts how many players in the match are not part of the group +/// Returns the count as a string, or an empty string if there is no group +String getExtraPlayerCount(Match match) { + int count = 0; + + if (match.group == null) { + return ''; + } + + final groupMembers = match.group!.members; + final players = match.players; + + for (var player in players) { + if (!groupMembers.any((member) => member.id == player.id)) { + count++; + } + } + + if (count == 0) { + return ''; + } + return ' + ${count.toString()}'; +} diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index 0e9fec2..d1b158e 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -27,6 +27,9 @@ class CustomTheme { /// Text color used throughout the app static const Color textColor = Color(0xFFFFFFFF); + /// Text color used throughout the app + static const Color hintColor = Color(0xFF888888); + /// Background color for the navigation bar static const Color navBarBackgroundColor = Color(0xFF131313); @@ -65,7 +68,7 @@ class CustomTheme { boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)], ); - // ==================== App Bar Theme ==================== + // ==================== Component Themes ==================== static const AppBarTheme appBarTheme = AppBarTheme( backgroundColor: backgroundColor, foregroundColor: textColor, @@ -80,4 +83,23 @@ class CustomTheme { ), iconTheme: IconThemeData(color: textColor), ); + + static const SearchBarThemeData searchBarTheme = SearchBarThemeData( + textStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.textColor)), + hintStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.hintColor)), + ); + + static final RadioThemeData radioTheme = RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return CustomTheme.primaryColor; + } + return CustomTheme.textColor; + }), + ); + + static const InputDecorationTheme inputDecorationTheme = InputDecorationTheme( + labelStyle: TextStyle(color: CustomTheme.textColor), + hintStyle: TextStyle(color: CustomTheme.hintColor), + ); } diff --git a/lib/core/enums.dart b/lib/core/enums.dart index d3e0610..6b33124 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -1,6 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:tallee/l10n/generated/app_localizations.dart'; - /// Button types used for styling the [CustomWidthButton] /// - [ButtonType.primary]: Primary button style. /// - [ButtonType.secondary]: Secondary button style. @@ -35,7 +32,13 @@ enum ExportResult { success, canceled, unknownException } /// - [Ruleset.singleWinner]: The match is won by a single player. /// - [Ruleset.singleLoser]: The match has a single loser. /// - [Ruleset.multipleWinners]: Multiple players can be winners. -enum Ruleset { highestScore, lowestScore, singleWinner, singleLoser, multipleWinners } +enum Ruleset { + highestScore, + lowestScore, + singleWinner, + singleLoser, + multipleWinners, +} /// Different colors available for games /// - [GameColor.red]: Red color @@ -47,20 +50,3 @@ enum Ruleset { highestScore, lowestScore, singleWinner, singleLoser, multipleWin /// - [GameColor.pink]: Pink color /// - [GameColor.teal]: Teal color enum GameColor { red, blue, green, yellow, purple, orange, pink, teal } - -/// Translates a [Ruleset] enum value to its corresponding localized string. -String translateRulesetToString(Ruleset ruleset, BuildContext context) { - final loc = AppLocalizations.of(context); - switch (ruleset) { - case Ruleset.highestScore: - return loc.highest_score; - case Ruleset.lowestScore: - return loc.lowest_score; - case Ruleset.singleWinner: - return loc.single_winner; - case Ruleset.singleLoser: - return loc.single_loser; - case Ruleset.multipleWinners: - return loc.multiple_winners; - } -} diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 5726df5..cc30b03 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -27,9 +27,9 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { if (row.groupId != null) { group = await db.groupDao.getGroupById(groupId: row.groupId!); } - final players = await db.playerMatchDao.getPlayersOfMatch( - matchId: row.id, - ) ?? []; + final players = + await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? []; + final winner = await getWinner(matchId: row.id); return Match( id: row.id, name: row.name ?? '', @@ -39,6 +39,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { notes: row.notes ?? '', createdAt: row.createdAt, endedAt: row.endedAt, + winner: winner, ); }), ); @@ -56,7 +57,10 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { group = await db.groupDao.getGroupById(groupId: result.groupId!); } - final players = await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; + final players = + await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; + + final winner = await getWinner(matchId: matchId); return Match( id: result.id, @@ -67,6 +71,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { notes: result.notes ?? '', createdAt: result.createdAt, endedAt: result.endedAt, + winner: winner, ); } @@ -94,6 +99,10 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { playerId: p.id, ); } + + if (match.winner != null) { + await setWinner(matchId: match.id, winnerId: match.winner!.id); + } }); } @@ -112,20 +121,20 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { if (uniqueGames.isNotEmpty) { await db.batch( - (b) => b.insertAll( + (b) => b.insertAll( db.gameTable, uniqueGames.values .map( (game) => GameTableCompanion.insert( - id: game.id, - name: game.name, - ruleset: game.ruleset.name, - description: game.description, - color: game.color.name, - icon: game.icon, - createdAt: game.createdAt, - ), - ) + id: game.id, + name: game.name, + ruleset: game.ruleset.name, + description: game.description, + color: game.color.name, + icon: game.icon, + createdAt: game.createdAt, + ), + ) .toList(), mode: InsertMode.insertOrIgnore, ), @@ -134,18 +143,18 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { // Add all groups of the matches in batch await db.batch( - (b) => b.insertAll( + (b) => b.insertAll( db.groupTable, matches .where((match) => match.group != null) .map( (match) => GroupTableCompanion.insert( - id: match.group!.id, - name: match.group!.name, - description: match.group!.description, - createdAt: match.group!.createdAt, - ), - ) + id: match.group!.id, + name: match.group!.name, + description: match.group!.description, + createdAt: match.group!.createdAt, + ), + ) .toList(), mode: InsertMode.insertOrIgnore, ), @@ -153,20 +162,20 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { // Add all matches in batch await db.batch( - (b) => b.insertAll( + (b) => b.insertAll( matchTable, matches .map( (match) => MatchTableCompanion.insert( - id: match.id, - gameId: match.game.id, - groupId: Value(match.group?.id), - name: Value(match.name), - notes: Value(match.notes), - createdAt: match.createdAt, - endedAt: Value(match.endedAt), - ), - ) + id: match.id, + gameId: match.game.id, + groupId: Value(match.group?.id), + name: Value(match.name), + notes: Value(match.notes), + createdAt: match.createdAt, + endedAt: Value(match.endedAt), + ), + ) .toList(), mode: InsertMode.insertOrReplace, ), @@ -188,17 +197,17 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { if (uniquePlayers.isNotEmpty) { await db.batch( - (b) => b.insertAll( + (b) => b.insertAll( db.playerTable, uniquePlayers.values .map( (p) => PlayerTableCompanion.insert( - id: p.id, - name: p.name, - description: p.description, - createdAt: p.createdAt, - ), - ) + id: p.id, + name: p.name, + description: p.description, + createdAt: p.createdAt, + ), + ) .toList(), mode: InsertMode.insertOrIgnore, ), @@ -253,9 +262,9 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { /// Retrieves the number of matches in the database. Future getMatchCount() async { final count = - await (selectOnly(matchTable)..addColumns([matchTable.id.count()])) - .map((row) => row.read(matchTable.id.count())) - .getSingle(); + await (selectOnly(matchTable)..addColumns([matchTable.id.count()])) + .map((row) => row.read(matchTable.id.count())) + .getSingle(); return count ?? 0; } @@ -315,15 +324,16 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { } /// Updates the group of the match with the given [matchId]. + /// Replaces the existing group association with the new group specified by [newGroupId]. /// Pass null to remove the group association. /// Returns `true` if more than 0 rows were affected, otherwise `false`. Future updateMatchGroup({ required String matchId, - required String? groupId, + required String? newGroupId, }) async { final query = update(matchTable)..where((g) => g.id.equals(matchId)); final rowsAffected = await query.write( - MatchTableCompanion(groupId: Value(groupId)), + MatchTableCompanion(groupId: Value(newGroupId)), ); return rowsAffected > 0; } @@ -379,10 +389,12 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { // Add the new players to the match await Future.wait( - newPlayers.map((player) => db.playerMatchDao.addPlayerToMatch( - matchId: matchId, - playerId: player.id, - )), + newPlayers.map( + (player) => db.playerMatchDao.addPlayerToMatch( + matchId: matchId, + playerId: player.id, + ), + ), ); }); } @@ -394,7 +406,8 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { /// Checks if a match has a winner. /// Returns true if any player in the match has their score set to 1. Future hasWinner({required String matchId}) async { - final players = await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; + final players = + await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; for (final player in players) { final score = await db.playerMatchDao.getPlayerScore( @@ -411,7 +424,8 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { /// Gets the winner of a match. /// Returns the player with score 1, or null if no winner is set. Future getWinner({required String matchId}) async { - final players = await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; + final players = + await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; for (final player in players) { final score = await db.playerMatchDao.getPlayerScore( @@ -433,7 +447,8 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { required String winnerId, }) async { await db.transaction(() async { - final players = await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; + final players = + await db.playerMatchDao.getPlayersOfMatch(matchId: matchId) ?? []; // Set all players' scores to 0 for (final player in players) { @@ -470,4 +485,4 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ); return success; } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index d0bacfc..cec565c 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -23,9 +23,10 @@ "delete": "Löschen", "delete_all_data": "Alle Daten löschen", "delete_group": "Diese Gruppe löschen", + "delete_match": "Spiel löschen", "edit_group": "Gruppe bearbeiten", - "delete_group": "Gruppe löschen", - "edit_group": "Gruppe bearbeiten", + "edit_match": "Gruppe bearbeiten", + "enter_results": "Ergebnisse eintragen", "error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", "error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen", "error_editing_group": "Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen", @@ -50,6 +51,7 @@ "licenses": "Lizenzen", "match_in_progress": "Spiel läuft...", "match_name": "Spieltitel", + "match_profile": "Spielprofil", "matches": "Spiele", "members": "Mitglieder", "most_points": "Höchste Punkte", @@ -62,6 +64,7 @@ "no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden", "no_players_selected": "Keine Spieler:innen ausgewählt", "no_recent_matches_available": "Keine letzten Spiele verfügbar", + "no_results_entered_yet": "Noch keine Ergebnisse eingetragen", "no_second_match_available": "Kein zweites Spiel verfügbar", "no_statistics_available": "Keine Statistiken verfügbar", "none": "Kein", @@ -74,11 +77,14 @@ "privacy_policy": "Datenschutzerklärung", "quick_create": "Schnellzugriff", "recent_matches": "Letzte Spiele", + "result": "Ergebnis", + "results": "Ergebnisse", "ruleset": "Regelwerk", "ruleset_least_points": "Umgekehrte Wertung: Der/die Spieler:in mit den wenigsten Punkten gewinnt.", "ruleset_most_points": "Traditionelles Regelwerk: Der/die Spieler:in mit den meisten Punkten gewinnt.", "ruleset_single_loser": "Genau ein:e Verlierer:in wird bestimmt; der letzte Platz erhält die Strafe oder Konsequenz.", "ruleset_single_winner": "Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.", + "save_changes": "Änderungen speichern", "search_for_groups": "Nach Gruppen suchen", "search_for_players": "Nach Spieler:innen suchen", "select_winner": "Gewinner:in wählen:", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e15f0c6..aea47f7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -74,9 +74,18 @@ "@delete_group": { "description": "Confirmation dialog for deleting a group" }, + "@delete_match": { + "description": "Button text to delete a match" + }, "@edit_group": { "description": "Button & Appbar label for editing a group" }, + "@edit_match": { + "description": "Button & Appbar label for editing a match" + }, + "@enter_results": { + "description": "Button text to enter match results" + }, "@error_creating_group": { "description": "Error message when group creation fails" }, @@ -149,6 +158,9 @@ "@match_name": { "description": "Placeholder for match name input" }, + "@match_profile": { + "description": "Title for match profile view" + }, "@matches": { "description": "Label for matches" }, @@ -185,6 +197,9 @@ "@no_recent_matches_available": { "description": "Message when no recent matches exist" }, + "@no_results_entered_yet": { + "description": "Message when no results have been entered yet" + }, "@no_second_match_available": { "description": "Message when no second match exists" }, @@ -226,6 +241,9 @@ "@recent_matches": { "description": "Title for recent matches section" }, + "@results": { + "description": "Label for match results" + }, "@ruleset": { "description": "Ruleset label" }, @@ -241,6 +259,9 @@ "@ruleset_single_winner": { "description": "Description for single winner ruleset" }, + "@save_changes": { + "description": "Save changes button text" + }, "@search_for_groups": { "description": "Hint text for group search input field" }, @@ -327,7 +348,10 @@ "delete": "Delete", "delete_all_data": "Delete all data", "delete_group": "Delete Group", + "delete_match": "Delete Match", "edit_group": "Edit Group", + "edit_match": "Edit Match", + "enter_results": "Enter Results", "error_creating_group": "Error while creating group, please try again", "error_deleting_group": "Error while deleting group, please try again", "error_editing_group": "Error while editing group, please try again", @@ -352,6 +376,7 @@ "licenses": "Licenses", "match_in_progress": "Match in progress...", "match_name": "Match name", + "match_profile": "Match Profile", "matches": "Matches", "members": "Members", "most_points": "Most Points", @@ -364,6 +389,7 @@ "no_players_found_with_that_name": "No players found with that name", "no_players_selected": "No players selected", "no_recent_matches_available": "No recent matches available", + "no_results_entered_yet": "No results entered yet", "no_second_match_available": "No second match available", "no_statistics_available": "No statistics available", "none": "None", @@ -376,11 +402,13 @@ "privacy_policy": "Privacy Policy", "quick_create": "Quick Create", "recent_matches": "Recent Matches", + "results": "Results", "ruleset": "Ruleset", "ruleset_least_points": "Inverse scoring: the player with the fewest points wins.", "ruleset_most_points": "Traditional ruleset: the player with the most points wins.", "ruleset_single_loser": "Exactly one loser is determined; last place receives the penalty or consequence.", "ruleset_single_winner": "Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.", + "save_changes": "Save Changes", "search_for_groups": "Search for groups", "search_for_players": "Search for players", "select_winner": "Select Winner:", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index af7f534..eb8a609 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -236,12 +236,30 @@ abstract class AppLocalizations { /// **'Delete Group'** String get delete_group; + /// Button text to delete a match + /// + /// In en, this message translates to: + /// **'Delete Match'** + String get delete_match; + /// Button & Appbar label for editing a group /// /// In en, this message translates to: /// **'Edit Group'** String get edit_group; + /// Button & Appbar label for editing a match + /// + /// In en, this message translates to: + /// **'Edit Match'** + String get edit_match; + + /// Button text to enter match results + /// + /// In en, this message translates to: + /// **'Enter Results'** + String get enter_results; + /// Error message when group creation fails /// /// In en, this message translates to: @@ -386,6 +404,12 @@ abstract class AppLocalizations { /// **'Match name'** String get match_name; + /// Title for match profile view + /// + /// In en, this message translates to: + /// **'Match Profile'** + String get match_profile; + /// Label for matches /// /// In en, this message translates to: @@ -458,6 +482,12 @@ abstract class AppLocalizations { /// **'No recent matches available'** String get no_recent_matches_available; + /// Message when no results have been entered yet + /// + /// In en, this message translates to: + /// **'No results entered yet'** + String get no_results_entered_yet; + /// Message when no second match exists /// /// In en, this message translates to: @@ -530,6 +560,12 @@ abstract class AppLocalizations { /// **'Recent Matches'** String get recent_matches; + /// Label for match results + /// + /// In en, this message translates to: + /// **'Results'** + String get results; + /// Ruleset label /// /// In en, this message translates to: @@ -560,6 +596,12 @@ abstract class AppLocalizations { /// **'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'** String get ruleset_single_winner; + /// Save changes button text + /// + /// In en, this message translates to: + /// **'Save Changes'** + String get save_changes; + /// Hint text for group search input field /// /// 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 b20ced8..501f9c6 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -79,11 +79,20 @@ class AppLocalizationsDe extends AppLocalizations { String get delete_all_data => 'Alle Daten löschen'; @override - String get delete_group => 'Gruppe löschen'; + String get delete_group => 'Diese Gruppe löschen'; + + @override + String get delete_match => 'Spiel löschen'; @override String get edit_group => 'Gruppe bearbeiten'; + @override + String get edit_match => 'Gruppe bearbeiten'; + + @override + String get enter_results => 'Ergebnisse eintragen'; + @override String get error_creating_group => 'Fehler beim Erstellen der Gruppe, bitte erneut versuchen'; @@ -159,6 +168,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get match_name => 'Spieltitel'; + @override + String get match_profile => 'Spielprofil'; + @override String get matches => 'Spiele'; @@ -196,6 +208,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get no_recent_matches_available => 'Keine letzten Spiele verfügbar'; + @override + String get no_results_entered_yet => 'Noch keine Ergebnisse eingetragen'; + @override String get no_second_match_available => 'Kein zweites Spiel verfügbar'; @@ -234,6 +249,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recent_matches => 'Letzte Spiele'; + @override + String get results => 'Ergebnisse'; + @override String get ruleset => 'Regelwerk'; @@ -253,6 +271,9 @@ class AppLocalizationsDe extends AppLocalizations { String get ruleset_single_winner => 'Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.'; + @override + String get save_changes => 'Änderungen speichern'; + @override String get search_for_groups => 'Nach Gruppen suchen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f1b2479..cdebc69 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -81,9 +81,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get delete_group => 'Delete Group'; + @override + String get delete_match => 'Delete Match'; + @override String get edit_group => 'Edit Group'; + @override + String get edit_match => 'Edit Match'; + + @override + String get enter_results => 'Enter Results'; + @override String get error_creating_group => 'Error while creating group, please try again'; @@ -159,6 +168,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get match_name => 'Match name'; + @override + String get match_profile => 'Match Profile'; + @override String get matches => 'Matches'; @@ -196,6 +208,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get no_recent_matches_available => 'No recent matches available'; + @override + String get no_results_entered_yet => 'No results entered yet'; + @override String get no_second_match_available => 'No second match available'; @@ -234,6 +249,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recent_matches => 'Recent Matches'; + @override + String get results => 'Results'; + @override String get ruleset => 'Ruleset'; @@ -253,6 +271,9 @@ class AppLocalizationsEn extends AppLocalizations { String get ruleset_single_winner => 'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'; + @override + String get save_changes => 'Save Changes'; + @override String get search_for_groups => 'Search for groups'; diff --git a/lib/main.dart b/lib/main.dart index 59384ac..f159ef7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,19 +29,31 @@ class GameTracker extends StatelessWidget { return supportedLocale; } } - return supportedLocales.firstWhere((locale) => locale.languageCode == 'en'); + return supportedLocales.firstWhere( + (locale) => locale.languageCode == 'en', + ); }, debugShowCheckedModeBanner: false, onGenerateTitle: (context) => AppLocalizations.of(context).app_name, - themeMode: ThemeMode.dark, // forces dark mode + themeMode: ThemeMode.dark, theme: ThemeData( + // main colors primaryColor: CustomTheme.primaryColor, scaffoldBackgroundColor: CustomTheme.backgroundColor, + // themes appBarTheme: CustomTheme.appBarTheme, + inputDecorationTheme: CustomTheme.inputDecorationTheme, + searchBarTheme: CustomTheme.searchBarTheme, + radioTheme: CustomTheme.radioTheme, + // color scheme colorScheme: ColorScheme.fromSeed( - seedColor: CustomTheme.primaryColor, + seedColor: CustomTheme.textColor, brightness: Brightness.dark, - ).copyWith(surface: CustomTheme.backgroundColor), + primary: CustomTheme.primaryColor, + onPrimary: CustomTheme.textColor, + surface: CustomTheme.backgroundColor, + onSurface: CustomTheme.textColor, + ), pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), diff --git a/lib/presentation/views/main_menu/custom_navigation_bar.dart b/lib/presentation/views/main_menu/custom_navigation_bar.dart index 3e1f865..16316ad 100644 --- a/lib/presentation/views/main_menu/custom_navigation_bar.dart +++ b/lib/presentation/views/main_menu/custom_navigation_bar.dart @@ -38,7 +38,7 @@ class _CustomNavigationBarState extends State ), KeyedSubtree( key: ValueKey('groups_$tabKeyCount'), - child: const GroupsView(), + child: const GroupView(), ), KeyedSubtree( key: ValueKey('stats_$tabKeyCount'), 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 6e145ea..ad88d66 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 @@ -43,6 +43,7 @@ class _GroupDetailViewState extends State { /// Total matches played in this group int totalMatches = 0; + /// The best player in this group String bestPlayer = ''; @override diff --git a/lib/presentation/views/main_menu/group_view/group_view.dart b/lib/presentation/views/main_menu/group_view/group_view.dart index 551f3ec..90b682a 100644 --- a/lib/presentation/views/main_menu/group_view/group_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_view.dart @@ -14,15 +14,15 @@ import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/tiles/group_tile.dart'; import 'package:tallee/presentation/widgets/top_centered_message.dart'; -class GroupsView extends StatefulWidget { +class GroupView extends StatefulWidget { /// A view that displays a list of groups - const GroupsView({super.key}); + const GroupView({super.key}); @override - State createState() => _GroupsViewState(); + State createState() => _GroupViewState(); } -class _GroupsViewState extends State { +class _GroupViewState extends State { late final AppDatabase db; /// Loaded groups from the database 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 447b9c5..d4d7f4d 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; 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 5720606..1bf732c 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 @@ -21,11 +21,22 @@ import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; class CreateMatchView extends StatefulWidget { /// A view that allows creating a new match /// [onWinnerChanged]: Optional callback invoked when the winner is changed - const CreateMatchView({super.key, this.onWinnerChanged}); + const CreateMatchView({ + super.key, + this.onWinnerChanged, + this.matchToEdit, + this.onMatchUpdated, + }); /// Optional callback invoked when the winner is changed final VoidCallback? onWinnerChanged; + /// Optional callback invoked when the match is updated + final void Function(Match)? onMatchUpdated; + + /// An optional match to prefill the fields + final Match? matchToEdit; + @override State createState() => _CreateMatchViewState(); } @@ -45,19 +56,9 @@ class _CreateMatchViewState extends State { /// List of all players from the database List playerList = []; - /// List of players filtered based on the selected group - /// If a group is selected, this list contains all players from [playerList] - /// who are not members of the selected group. If no group is selected, - /// this list is identical to [playerList]. - List filteredPlayerList = []; - /// The currently selected group Group? selectedGroup; - /// The index of the currently selected group in [groupsList] to mark it in - /// the [ChooseGroupView] - String selectedGroupId = ''; - /// The index of the currently selected game in [games] to mark it in /// the [ChooseGameView] int selectedGameIndex = -1; @@ -83,9 +84,11 @@ class _CreateMatchViewState extends State { ]).then((result) async { groupsList = result[0] as List; playerList = result[1] as List; - setState(() { - filteredPlayerList = List.from(playerList); - }); + + // If a match is provided, prefill the fields + if (widget.matchToEdit != null) { + prefillMatchDetails(); + } }); } @@ -110,12 +113,19 @@ class _CreateMatchViewState extends State { @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); + final buttonText = widget.matchToEdit != null + ? loc.save_changes + : loc.create_match; + final viewTitle = widget.matchToEdit != null + ? loc.edit_match + : loc.create_new_match; + return ScaffoldMessenger( key: _scaffoldMessengerKey, child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: CustomTheme.backgroundColor, - appBar: AppBar(title: Text(loc.create_new_match)), + appBar: AppBar(title: Text(viewTitle)), body: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -157,96 +167,55 @@ class _CreateMatchViewState extends State { ? loc.none_group : 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: selectedGroupId, + initialGroupId: selectedGroup?.id ?? '', ), ), ); - selectedGroupId = selectedGroup?.id ?? ''; - if (selectedGroup != null) { - filteredPlayerList = playerList - .where( - (p) => - !selectedGroup!.members.any((m) => m.id == p.id), - ) - .toList(); - } else { - filteredPlayerList = List.from(playerList); - } - setState(() {}); + + setState(() { + if (selectedGroup != null) { + setState(() { + selectedPlayers += [...selectedGroup!.members]; + }); + } + }); }, ), Expanded( child: PlayerSelection( key: ValueKey(selectedGroup?.id ?? 'no_group'), initialSelectedPlayers: selectedPlayers, - availablePlayers: filteredPlayerList, onChanged: (value) { setState(() { selectedPlayers = value; + removeGroupWhenNoMemberLeft(); }); }, ), ), CustomWidthButton( - text: loc.create_match, + text: buttonText, sizeRelativeToWidth: 0.95, buttonType: ButtonType.primary, onPressed: _enableCreateGameButton() - ? () async { - // Use a game from the games list - Game? gameToUse; - if (selectedGameIndex == -1) { - // Use the first game as default if none selected - final selectedGame = games[0]; - gameToUse = Game( - name: selectedGame.$1, - description: selectedGame.$2, - ruleset: selectedGame.$3, - color: GameColor.blue, - icon: '', - ); - } else { - // Use the selected game from the list - final selectedGame = games[selectedGameIndex]; - gameToUse = Game( - name: selectedGame.$1, - description: selectedGame.$2, - ruleset: selectedGame.$3, - color: GameColor.blue, - icon: '', - ); - } - // Add the game to the database if it doesn't exist - await db.gameDao.addGame(game: gameToUse); - - Match match = Match( - name: _matchNameController.text.isEmpty - ? (hintText ?? '') - : _matchNameController.text.trim(), - createdAt: DateTime.now(), - game: gameToUse, - group: selectedGroup, - players: selectedPlayers, - notes: '', - ); - await db.matchDao.addMatch(match: match); - if (context.mounted) { - Navigator.pushReplacement( - context, - adaptivePageRoute( - fullscreenDialog: true, - builder: (context) => MatchResultView( - match: match, - onWinnerChanged: widget.onWinnerChanged, - ), - ), - ); - } - } + ? () { + buttonNavigation(context); + } : null, ), ], @@ -263,6 +232,155 @@ class _CreateMatchViewState extends State { /// - Either a group is selected OR at least 2 players are selected bool _enableCreateGameButton() { return (selectedGroup != null || - (selectedPlayers.length > 1)); + (selectedPlayers.length > 1) && selectedGameIndex != -1); } -} \ No newline at end of file + + // If a match was provided to the view, it updates the match in the database + // and navigates back to the previous screen. + // If no match was provided, it creates a new match in the database and + // navigates to the MatchResultView for the newly created match. + void buttonNavigation(BuildContext context) async { + if (widget.matchToEdit != null) { + await updateMatch(); + if (context.mounted) { + Navigator.pop(context); + } + } else { + final match = await createMatch(); + + if (context.mounted) { + Navigator.pushReplacement( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => MatchResultView( + match: match, + onWinnerChanged: widget.onWinnerChanged, + ), + ), + ); + } + } + } + + /// Updates attributes of the existing match in the database based on the + /// changes made in the edit view. + Future updateMatch() async { + //TODO: Remove when Games implemented + final tempGame = await getTemporaryGame(); + + final updatedMatch = Match( + id: widget.matchToEdit!.id, + name: _matchNameController.text.isEmpty + ? (hintText ?? '') + : _matchNameController.text.trim(), + group: selectedGroup, + players: selectedPlayers, + game: tempGame, + winner: widget.matchToEdit!.winner, + createdAt: widget.matchToEdit!.createdAt, + endedAt: widget.matchToEdit!.endedAt, + notes: widget.matchToEdit!.notes, + ); + + if (widget.matchToEdit!.name != updatedMatch.name) { + await db.matchDao.updateMatchName( + matchId: widget.matchToEdit!.id, + newName: updatedMatch.name, + ); + } + + if (widget.matchToEdit!.group?.id != updatedMatch.group?.id) { + await db.matchDao.updateMatchGroup( + matchId: widget.matchToEdit!.id, + newGroupId: updatedMatch.group?.id, + ); + } + + // Add players who are in updatedMatch but not in the original match + for (var player in updatedMatch.players) { + if (!widget.matchToEdit!.players.any((p) => p.id == player.id)) { + await db.playerMatchDao.addPlayerToMatch( + matchId: widget.matchToEdit!.id, + playerId: player.id, + ); + } + } + + // Remove players who are in the original match but not in updatedMatch + for (var player in widget.matchToEdit!.players) { + if (!updatedMatch.players.any((p) => p.id == player.id)) { + await db.playerMatchDao.removePlayerFromMatch( + matchId: widget.matchToEdit!.id, + playerId: player.id, + ); + if (widget.matchToEdit!.winner?.id == player.id) { + updatedMatch.winner = null; + } + } + } + + widget.onMatchUpdated?.call(updatedMatch); + } + + // Creates a new match and adds it to the database. + // Returns the created match. + Future createMatch() async { + final tempGame = await getTemporaryGame(); + + Match match = Match( + name: _matchNameController.text.isEmpty + ? (hintText ?? '') + : _matchNameController.text.trim(), + createdAt: DateTime.now(), + group: selectedGroup, + players: selectedPlayers, + game: tempGame, + ); + await db.matchDao.addMatch(match: match); + return match; + } + + // TODO: Remove when games fully implemented + Future getTemporaryGame() async { + Game? game; + + final selectedGame = games[selectedGameIndex]; + game = Game( + name: selectedGame.$1, + description: selectedGame.$2, + ruleset: selectedGame.$3, + color: GameColor.blue, + icon: '', + ); + + await db.gameDao.addGame(game: game); + return game; + } + + // If a match was provided to the view, this method prefills the input fields + void prefillMatchDetails() { + final match = widget.matchToEdit!; + _matchNameController.text = match.name; + selectedPlayers = match.players; + + 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/match_detail_view.dart b/lib/presentation/views/main_menu/match_view/match_detail_view.dart new file mode 100644 index 0000000..1deba18 --- /dev/null +++ b/lib/presentation/views/main_menu/match_view/match_detail_view.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:tallee/core/adaptive_page_route.dart'; +import 'package:tallee/core/common.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/data/db/database.dart'; +import 'package:tallee/data/dto/match.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_match_view.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; +import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart'; +import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; +import 'package:tallee/presentation/widgets/colored_icon_container.dart'; +import 'package:tallee/presentation/widgets/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; + +class MatchDetailView extends StatefulWidget { + /// A view that displays the profile of a match + /// - [match]: The match to display + /// - [onMatchUpdate]: Callback to refresh the match list + const MatchDetailView({ + super.key, + required this.match, + required this.onMatchUpdate, + }); + + /// The match to display + final Match match; + + /// Callback to refresh the match list + final VoidCallback onMatchUpdate; + + @override + State createState() => _MatchDetailViewState(); +} + +class _MatchDetailViewState extends State { + late final AppDatabase db; + + late Match match; + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + match = widget.match; + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + title: Text(loc.match_profile), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + showDialog( + context: context, + builder: (context) => CustomAlertDialog( + title: '${loc.delete_match}?', + content: loc.this_cannot_be_undone, + actions: [ + AnimatedDialogButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + loc.cancel, + style: const TextStyle(color: CustomTheme.textColor), + ), + ), + AnimatedDialogButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + loc.delete, + style: const TextStyle( + color: CustomTheme.secondaryColor, + ), + ), + ), + ], + ), + ).then((confirmed) async { + if (confirmed! && context.mounted) { + await db.matchDao.deleteMatch(matchId: match.id); + if (!context.mounted) return; + Navigator.pop(context); + widget.onMatchUpdate.call(); + } + }); + }, + ), + ], + ), + body: SafeArea( + child: Stack( + alignment: Alignment.center, + children: [ + ListView( + padding: const EdgeInsets.only( + left: 12, + right: 12, + top: 20, + bottom: 100, + ), + children: [ + const Center( + child: ColoredIconContainer( + icon: Icons.sports_esports, + containerSize: 55, + iconSize: 38, + ), + ), + const SizedBox(height: 10), + Text( + match.name, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 5), + Text( + '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(match.createdAt)}', + style: const TextStyle( + fontSize: 12, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + if (match.group != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.group), + const SizedBox(width: 8), + Text( + '${match.group!.name}${getExtraPlayerCount(match)}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 20), + ], + 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, + iconEnabled: false, + ); + }).toList(), + ), + ), + const SizedBox(height: 15), + InfoTile( + title: loc.results, + icon: Icons.emoji_events, + content: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + /// TODO: Implement different ruleset results display + if (match.winner != null) ...[ + Text( + loc.winner, + style: const TextStyle( + fontSize: 16, + color: CustomTheme.textColor, + ), + ), + Text( + match.winner!.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ] else ...[ + Text( + loc.no_results_entered_yet, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.textColor, + ), + ), + ], + ], + ), + ), + ), + ], + ), + Positioned( + bottom: MediaQuery.paddingOf(context).bottom, + child: Row( + children: [ + MainMenuButton( + icon: Icons.edit, + onPressed: () => Navigator.push( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => CreateMatchView( + matchToEdit: match, + onMatchUpdated: onMatchUpdated, + ), + ), + ), + ), + const SizedBox(width: 15), + MainMenuButton( + text: loc.enter_results, + icon: Icons.emoji_events, + onPressed: () async { + match.winner = await Navigator.push( + context, + adaptivePageRoute( + fullscreenDialog: true, + builder: (context) => MatchResultView( + match: match, + onWinnerChanged: () { + widget.onMatchUpdate.call(); + setState(() {}); + }, + ), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Callback for when the match is updated in the edit view, + /// updates the match in this view + void onMatchUpdated(Match editedMatch) { + setState(() { + match = editedMatch; + }); + widget.onMatchUpdate.call(); + } +} 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 4f3f0c0..8a46f13 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 @@ -35,7 +35,10 @@ class _MatchResultViewState extends State { @override void initState() { db = Provider.of(context, listen: false); - allPlayers = getAllPlayers(widget.match); + + allPlayers = widget.match.players; + allPlayers.sort((a, b) => a.name.compareTo(b.name)); + if (widget.match.winner != null) { _selectedPlayer = allPlayers.firstWhere( (p) => p.id == widget.match.winner!.id, @@ -54,7 +57,7 @@ class _MatchResultViewState extends State { icon: const Icon(Icons.close), onPressed: () { widget.onWinnerChanged?.call(); - Navigator.of(context).pop(); + Navigator.of(context).pop(_selectedPlayer); }, ), title: Text(widget.match.name), @@ -145,21 +148,4 @@ class _MatchResultViewState extends State { } widget.onWinnerChanged?.call(); } - - /// Retrieves all players associated with the given [match]. - /// This includes players directly assigned to the match - /// as well as members of the group (if any). - /// The returned list is sorted alphabetically by player name. - List getAllPlayers(Match match) { - List players = []; - - if (match.group == null) { - players = [...match.players]; - } else { - players = [...match.players, ...match.group!.members]; - } - - players.sort((a, b) => a.name.compareTo(b.name)); - return players; - } } 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 1f50342..a090b46 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -1,5 +1,3 @@ -import 'dart:core' hide Match; - import 'package:flutter/material.dart'; import 'package:fluttericon/rpg_awesome_icons.dart'; import 'package:provider/provider.dart'; @@ -14,7 +12,7 @@ import 'package:tallee/data/dto/match.dart'; import 'package:tallee/data/dto/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_match_view.dart'; -import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart'; +import 'package:tallee/presentation/views/main_menu/match_view/match_detail_view.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/tiles/match_tile.dart'; @@ -38,7 +36,13 @@ class _MatchViewState extends State { 4, Match( name: 'Skeleton match name', - game: Game(name: '', ruleset: Ruleset.singleWinner, description: '', color: GameColor.blue, icon: ''), + game: Game( + name: '', + ruleset: Ruleset.singleWinner, + description: '', + color: GameColor.blue, + icon: '', + ), group: Group( name: 'Group name', description: '', @@ -54,7 +58,7 @@ class _MatchViewState extends State { void initState() { super.initState(); db = Provider.of(context, listen: false); - loadGames(); + loadMatches(); } @override @@ -94,10 +98,9 @@ class _MatchViewState extends State { Navigator.push( context, adaptivePageRoute( - fullscreenDialog: true, - builder: (context) => MatchResultView( + builder: (context) => MatchDetailView( match: matches[index], - onWinnerChanged: loadGames, + onMatchUpdate: loadMatches, ), ), ); @@ -120,7 +123,7 @@ class _MatchViewState extends State { context, adaptivePageRoute( builder: (context) => - CreateMatchView(onWinnerChanged: loadGames), + CreateMatchView(onWinnerChanged: loadMatches), ), ); }, @@ -131,8 +134,9 @@ class _MatchViewState extends State { ); } - /// Loads the games from the database and sorts them by creation date. - void loadGames() { + /// Loads the matches from the database and sorts them by creation date. + void loadMatches() { + isLoading = true; Future.wait([ db.matchDao.getAllMatches(), Future.delayed(Constants.MINIMUM_SKELETON_DURATION), @@ -140,7 +144,7 @@ class _MatchViewState extends State { if (mounted) { setState(() { final loadedMatches = results[0] as List; - matches = loadedMatches + matches = [...loadedMatches] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); isLoading = false; }); diff --git a/lib/presentation/widgets/buttons/main_menu_button.dart b/lib/presentation/widgets/buttons/main_menu_button.dart index 747c31e..c583456 100644 --- a/lib/presentation/widgets/buttons/main_menu_button.dart +++ b/lib/presentation/widgets/buttons/main_menu_button.dart @@ -2,25 +2,25 @@ import 'package:flutter/material.dart'; class MainMenuButton extends StatefulWidget { /// A button for the main menu with an optional icon and a press animation. - /// - [text]: The text of the button. - /// - [icon]: The icon of the button. /// - [onPressed]: The callback to be invoked when the button is pressed. + /// - [icon]: The icon of the button. + /// - [text]: The text of the button. const MainMenuButton({ super.key, - required this.text, - this.icon, required this.onPressed, + required this.icon, + this.text, }); - /// The text of the button. - final String text; - - /// The icon of the button. - final IconData? icon; - /// The callback to be invoked when the button is pressed. final void Function() onPressed; + /// The icon of the button. + final IconData icon; + + /// The text of the button. + final String? text; + @override State createState() => _MainMenuButtonState(); } @@ -71,18 +71,18 @@ class _MainMenuButtonState extends State mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - if (widget.icon != null) ...[ - Icon(widget.icon, size: 26, color: Colors.black), + Icon(widget.icon, size: 26, color: Colors.black), + if (widget.text != null) ...[ const SizedBox(width: 7), - ], - Text( - widget.text, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.black, + Text( + widget.text!, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), + ], ], ), ), diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index cc3a68c..b51cadc 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -110,7 +110,9 @@ class _PlayerSelectionState extends State { final bool nameMatches = player.name.toLowerCase().contains( value.toLowerCase(), ); - final bool isNotSelected = !selectedPlayers.any((p) => p.id == player.id); + final bool isNotSelected = !selectedPlayers.any( + (p) => p.id == player.id, + ); return nameMatches && isNotSelected; }).toList(); } @@ -224,49 +226,53 @@ class _PlayerSelectionState extends State { db.playerDao.getAllPlayers(), Future.delayed(Constants.MINIMUM_SKELETON_DURATION), ]).then((results) => results[0] as List); - if (mounted) { - _allPlayersFuture.then((loadedPlayers) { - setState(() { - // If a list of available players is provided (even if empty), use that list. - if (widget.availablePlayers != null) { - widget.availablePlayers!.sort((a, b) => a.name.compareTo(b.name)); - allPlayers = [...widget.availablePlayers!]; - suggestedPlayers = [...allPlayers]; - if (widget.initialSelectedPlayers != null) { - // Ensures that only players available for selection are pre-selected. - selectedPlayers = widget.initialSelectedPlayers! - .where( - (p) => widget.availablePlayers!.any( - (available) => available.id == p.id, - ), - ) - .toList(); - } - } else { - // Otherwise, use the loaded players from the database. - loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); - allPlayers = [...loadedPlayers]; - if (widget.initialSelectedPlayers != null) { - // Excludes already selected players from the suggested players list. - suggestedPlayers = loadedPlayers.where((p) => !widget.initialSelectedPlayers!.any((ip) => ip.id == p.id)).toList(); - // Ensures that only players available for selection are pre-selected. - selectedPlayers = widget.initialSelectedPlayers! - .where( - (p) => allPlayers.any( - (available) => available.id == p.id, - ), - ) - .toList(); - } else { - // If no initial selection, all loaded players are suggested. - suggestedPlayers = [...loadedPlayers]; - } + _allPlayersFuture.then((loadedPlayers) { + if (!mounted) return; + setState(() { + // If a list of available players is provided (even if empty), use that list. + if (widget.availablePlayers != null) { + widget.availablePlayers!.sort((a, b) => a.name.compareTo(b.name)); + allPlayers = [...widget.availablePlayers!]; + suggestedPlayers = [...allPlayers]; + + if (widget.initialSelectedPlayers != null) { + // Ensures that only players available for selection are pre-selected. + selectedPlayers = widget.initialSelectedPlayers! + .where( + (p) => widget.availablePlayers!.any( + (available) => available.id == p.id, + ), + ) + .toList(); } - isLoading = false; - }); + } else { + // Otherwise, use the loaded players from the database. + loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); + allPlayers = [...loadedPlayers]; + if (widget.initialSelectedPlayers != null) { + // Excludes already selected players from the suggested players list. + suggestedPlayers = loadedPlayers + .where( + (p) => !widget.initialSelectedPlayers!.any( + (ip) => ip.id == p.id, + ), + ) + .toList(); + // Ensures that only players available for selection are pre-selected. + selectedPlayers = widget.initialSelectedPlayers! + .where( + (p) => allPlayers.any((available) => available.id == p.id), + ) + .toList(); + } else { + // If no initial selection, all loaded players are suggested. + suggestedPlayers = [...loadedPlayers]; + } + } + isLoading = false; }); - } + }); } /// Adds a new player to the database from the search bar input. diff --git a/lib/presentation/widgets/text_input/custom_search_bar.dart b/lib/presentation/widgets/text_input/custom_search_bar.dart index 313fc1a..e5fc498 100644 --- a/lib/presentation/widgets/text_input/custom_search_bar.dart +++ b/lib/presentation/widgets/text_input/custom_search_bar.dart @@ -69,7 +69,6 @@ class CustomSearchBar extends StatelessWidget { constraints ?? const BoxConstraints(maxHeight: 45, minHeight: 45), hintText: hintText, onChanged: onChanged, - hintStyle: WidgetStateProperty.all(const TextStyle(fontSize: 16)), leading: const Icon(Icons.search), trailing: [ Visibility( diff --git a/lib/presentation/widgets/tiles/custom_radio_list_tile.dart b/lib/presentation/widgets/tiles/custom_radio_list_tile.dart index 53d0a03..5559d10 100644 --- a/lib/presentation/widgets/tiles/custom_radio_list_tile.dart +++ b/lib/presentation/widgets/tiles/custom_radio_list_tile.dart @@ -36,11 +36,7 @@ class CustomRadioListTile extends StatelessWidget { ), child: Row( children: [ - Radio( - value: value, - activeColor: CustomTheme.primaryColor, - toggleable: true, - ), + Radio(value: value, toggleable: true), Expanded( child: Text( text, diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index 7862000..3c36587 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -1,8 +1,10 @@ +import 'dart:core' hide Match; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:tallee/core/common.dart'; import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/data/dto/match.dart'; -import 'package:tallee/data/dto/player.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -38,18 +40,13 @@ class MatchTile extends StatefulWidget { } class _MatchTileState extends State { - late final List _allPlayers; - - @override - void initState() { - super.initState(); - _allPlayers = _getCombinedPlayers(); - } - @override Widget build(BuildContext context) { - final group = widget.match.group; - final winner = widget.match.winner; + final match = widget.match; + final group = match.group; + final winner = match.winner; + final players = [...match.players] + ..sort((a, b) => a.name.compareTo(b.name)); final loc = AppLocalizations.of(context); return GestureDetector( @@ -67,7 +64,7 @@ class _MatchTileState extends State { children: [ Expanded( child: Text( - widget.match.name, + match.name, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -76,7 +73,7 @@ class _MatchTileState extends State { ), ), Text( - _formatDate(widget.match.createdAt, context), + _formatDate(match.createdAt, context), style: const TextStyle(fontSize: 12, color: Colors.grey), ), ], @@ -91,7 +88,7 @@ class _MatchTileState extends State { const SizedBox(width: 6), Expanded( child: Text( - '${group.name} + ${widget.match.players.length}', + '${match.group!.name}${getExtraPlayerCount(match)}', style: const TextStyle(fontSize: 14, color: Colors.grey), overflow: TextOverflow.ellipsis, ), @@ -106,7 +103,7 @@ class _MatchTileState extends State { const SizedBox(width: 6), Expanded( child: Text( - '${widget.match.players.length} ${loc.players}', + '${match.players.length} ${loc.players}', style: const TextStyle(fontSize: 14, color: Colors.grey), overflow: TextOverflow.ellipsis, ), @@ -192,7 +189,7 @@ class _MatchTileState extends State { const SizedBox(height: 12), ], - if (_allPlayers.isNotEmpty && widget.compact == false) ...[ + if (players.isNotEmpty && widget.compact == false) ...[ Text( loc.players, style: const TextStyle( @@ -205,7 +202,7 @@ class _MatchTileState extends State { Wrap( spacing: 6, runSpacing: 6, - children: _allPlayers.map((player) { + children: players.map((player) { return TextIconTile(text: player.name, iconEnabled: false); }).toList(), ), @@ -233,32 +230,4 @@ class _MatchTileState extends State { return '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(dateTime)}'; } } - - /// Retrieves all unique players associated with the match, - /// combining players from both the match and its group. - List _getCombinedPlayers() { - final allPlayers = []; - final playerIds = {}; - - // Add players from game.players - for (var player in widget.match.players) { - if (!playerIds.contains(player.id)) { - allPlayers.add(player); - playerIds.add(player.id); - } - } - - // Add players from game.group.players - if (widget.match.group?.members != null) { - for (var player in widget.match.group!.members) { - if (!playerIds.contains(player.id)) { - allPlayers.add(player); - playerIds.add(player.id); - } - } - } - - allPlayers.sort((a, b) => a.name.compareTo(b.name)); - return allPlayers; - } } diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index 0718e0d..ea80369 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -1,13 +1,13 @@ import 'package:clock/clock.dart'; -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' hide isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tallee/core/enums.dart'; import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/dto/game.dart'; import 'package:tallee/data/dto/group.dart'; import 'package:tallee/data/dto/match.dart'; import 'package:tallee/data/dto/player.dart'; -import 'package:tallee/core/enums.dart'; void main() { late AppDatabase database; @@ -51,7 +51,13 @@ void main() { description: '', members: [testPlayer4, testPlayer5], ); - testGame = Game(name: 'Test Game', ruleset: Ruleset.singleWinner, description: 'A test game', color: GameColor.blue, icon: ''); + testGame = Game( + name: 'Test Game', + ruleset: Ruleset.singleWinner, + description: 'A test game', + color: GameColor.blue, + icon: '', + ); testMatch1 = Match( name: 'First Test Match', game: testGame, @@ -99,7 +105,6 @@ void main() { }); group('Match Tests', () { - // Verifies that a single match can be added and retrieved with all fields, group, and players intact. test('Adding and fetching single match works correctly', () async { await database.matchDao.addMatch(match: testMatch1); @@ -127,10 +132,7 @@ void main() { for (int i = 0; i < testMatch1.players.length; i++) { expect(result.players[i].id, testMatch1.players[i].id); expect(result.players[i].name, testMatch1.players[i].name); - expect( - result.players[i].createdAt, - testMatch1.players[i].createdAt, - ); + expect(result.players[i].createdAt, testMatch1.players[i].createdAt); } }); @@ -191,10 +193,7 @@ void main() { for (int i = 0; i < testMatch.players.length; i++) { expect(match.players[i].id, testMatch.players[i].id); expect(match.players[i].name, testMatch.players[i].name); - expect( - match.players[i].createdAt, - testMatch.players[i].createdAt, - ); + expect(match.players[i].createdAt, testMatch.players[i].createdAt); } } }); @@ -282,5 +281,31 @@ void main() { ); expect(fetchedMatch.name, newName); }); + + test('Fetching a winner works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + var fetchedMatch = await database.matchDao.getMatchById( + matchId: testMatch1.id, + ); + + expect(fetchedMatch.winner, isNotNull); + expect(fetchedMatch.winner!.id, testPlayer4.id); + }); + + test('Setting a winner works correctly', () async { + await database.matchDao.addMatch(match: testMatch1); + + await database.matchDao.setWinner( + matchId: testMatch1.id, + winnerId: testPlayer5.id, + ); + + final fetchedMatch = await database.matchDao.getMatchById( + matchId: testMatch1.id, + ); + expect(fetchedMatch.winner, isNotNull); + expect(fetchedMatch.winner!.id, testPlayer5.id); + }); }); }