Bearbeiten und Löschen von Matches #171

Merged
sneeex merged 64 commits from feature/120-bearbeiten-und-loeschen-von-matches into development 2026-03-08 08:28:10 +00:00
31 changed files with 1152 additions and 406 deletions

View File

@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>

View File

@@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }

View File

@@ -31,6 +31,27 @@
</array> </array>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

45
lib/core/common.dart Normal file
View File

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

View File

@@ -27,6 +27,9 @@ class CustomTheme {
/// Text color used throughout the app /// Text color used throughout the app
static const Color textColor = Color(0xFFFFFFFF); static const Color textColor = Color(0xFFFFFFFF);
/// Text color used throughout the app
static const Color hintColor = Color(0xFF888888);
/// Background color for the navigation bar /// Background color for the navigation bar
static const Color navBarBackgroundColor = Color(0xFF131313); static const Color navBarBackgroundColor = Color(0xFF131313);
@@ -65,7 +68,7 @@ class CustomTheme {
boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)], boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)],
); );
// ==================== App Bar Theme ==================== // ==================== Component Themes ====================
static const AppBarTheme appBarTheme = AppBarTheme( static const AppBarTheme appBarTheme = AppBarTheme(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
foregroundColor: textColor, foregroundColor: textColor,
@@ -80,4 +83,23 @@ class CustomTheme {
), ),
iconTheme: IconThemeData(color: textColor), 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<Color>((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),
);
} }

View File

@@ -1,6 +1,3 @@
import 'package:flutter/material.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
/// Button types used for styling the [CustomWidthButton] /// Button types used for styling the [CustomWidthButton]
/// - [ButtonType.primary]: Primary button style. /// - [ButtonType.primary]: Primary button style.
/// - [ButtonType.secondary]: Secondary 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.singleWinner]: The match is won by a single player.
/// - [Ruleset.singleLoser]: The match has a single loser. /// - [Ruleset.singleLoser]: The match has a single loser.
/// - [Ruleset.multipleWinners]: Multiple players can be winners. /// - [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 /// Different colors available for games
/// - [GameColor.red]: Red color /// - [GameColor.red]: Red color
@@ -47,20 +50,3 @@ enum Ruleset { highestScore, lowestScore, singleWinner, singleLoser, multipleWin
/// - [GameColor.pink]: Pink color /// - [GameColor.pink]: Pink color
/// - [GameColor.teal]: Teal color /// - [GameColor.teal]: Teal color
enum GameColor { red, blue, green, yellow, purple, orange, pink, teal } 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;
}
}

View File

@@ -27,9 +27,9 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
if (row.groupId != null) { if (row.groupId != null) {
group = await db.groupDao.getGroupById(groupId: row.groupId!); group = await db.groupDao.getGroupById(groupId: row.groupId!);
} }
final players = await db.playerMatchDao.getPlayersOfMatch( final players =
matchId: row.id, await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? [];
) ?? []; final winner = await getWinner(matchId: row.id);
return Match( return Match(
id: row.id, id: row.id,
name: row.name ?? '', name: row.name ?? '',
@@ -39,6 +39,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
notes: row.notes ?? '', notes: row.notes ?? '',
createdAt: row.createdAt, createdAt: row.createdAt,
endedAt: row.endedAt, endedAt: row.endedAt,
winner: winner,
); );
}), }),
); );
@@ -56,7 +57,10 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
group = await db.groupDao.getGroupById(groupId: result.groupId!); 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( return Match(
id: result.id, id: result.id,
@@ -67,6 +71,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
notes: result.notes ?? '', notes: result.notes ?? '',
createdAt: result.createdAt, createdAt: result.createdAt,
endedAt: result.endedAt, endedAt: result.endedAt,
winner: winner,
); );
} }
@@ -94,6 +99,10 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
playerId: p.id, playerId: p.id,
); );
} }
if (match.winner != null) {
await setWinner(matchId: match.id, winnerId: match.winner!.id);
}
}); });
} }
@@ -315,15 +324,16 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
} }
/// Updates the group of the match with the given [matchId]. /// 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. /// Pass null to remove the group association.
/// Returns `true` if more than 0 rows were affected, otherwise `false`. /// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> updateMatchGroup({ Future<bool> updateMatchGroup({
required String matchId, required String matchId,
required String? groupId, required String? newGroupId,
}) async { }) async {
final query = update(matchTable)..where((g) => g.id.equals(matchId)); final query = update(matchTable)..where((g) => g.id.equals(matchId));
final rowsAffected = await query.write( final rowsAffected = await query.write(
MatchTableCompanion(groupId: Value(groupId)), MatchTableCompanion(groupId: Value(newGroupId)),
); );
return rowsAffected > 0; return rowsAffected > 0;
} }
@@ -379,10 +389,12 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
// Add the new players to the match // Add the new players to the match
await Future.wait( await Future.wait(
newPlayers.map((player) => db.playerMatchDao.addPlayerToMatch( newPlayers.map(
(player) => db.playerMatchDao.addPlayerToMatch(
matchId: matchId, matchId: matchId,
playerId: player.id, playerId: player.id,
)), ),
),
); );
}); });
} }
@@ -394,7 +406,8 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Checks if a match has a winner. /// Checks if a match has a winner.
/// Returns true if any player in the match has their score set to 1. /// Returns true if any player in the match has their score set to 1.
Future<bool> hasWinner({required String matchId}) async { Future<bool> 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) { for (final player in players) {
final score = await db.playerMatchDao.getPlayerScore( final score = await db.playerMatchDao.getPlayerScore(
@@ -411,7 +424,8 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Gets the winner of a match. /// Gets the winner of a match.
/// Returns the player with score 1, or null if no winner is set. /// Returns the player with score 1, or null if no winner is set.
Future<Player?> getWinner({required String matchId}) async { Future<Player?> 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) { for (final player in players) {
final score = await db.playerMatchDao.getPlayerScore( final score = await db.playerMatchDao.getPlayerScore(
@@ -433,7 +447,8 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
required String winnerId, required String winnerId,
}) async { }) async {
await db.transaction(() 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 // Set all players' scores to 0
for (final player in players) { for (final player in players) {

View File

@@ -22,9 +22,14 @@
"days_ago": "vor {count} Tagen", "days_ago": "vor {count} Tagen",
"delete": "Löschen", "delete": "Löschen",
"delete_all_data": "Alle Daten löschen", "delete_all_data": "Alle Daten löschen",
"delete_group": "Gruppe löschen", "delete_group": "Diese Gruppe löschen",
"delete_match": "Spiel löschen",
"edit_group": "Gruppe bearbeiten", "edit_group": "Gruppe bearbeiten",
"edit_match": "Gruppe bearbeiten",
"enter_results": "Ergebnisse eintragen",
"error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", "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",
"error_reading_file": "Fehler beim Lesen der Datei", "error_reading_file": "Fehler beim Lesen der Datei",
"export_canceled": "Export abgebrochen", "export_canceled": "Export abgebrochen",
"export_data": "Daten exportieren", "export_data": "Daten exportieren",
@@ -46,6 +51,7 @@
"licenses": "Lizenzen", "licenses": "Lizenzen",
"match_in_progress": "Spiel läuft...", "match_in_progress": "Spiel läuft...",
"match_name": "Spieltitel", "match_name": "Spieltitel",
"match_profile": "Spielprofil",
"matches": "Spiele", "matches": "Spiele",
"members": "Mitglieder", "members": "Mitglieder",
"most_points": "Höchste Punkte", "most_points": "Höchste Punkte",
@@ -58,6 +64,7 @@
"no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden", "no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden",
"no_players_selected": "Keine Spieler:innen ausgewählt", "no_players_selected": "Keine Spieler:innen ausgewählt",
"no_recent_matches_available": "Keine letzten Spiele verfügbar", "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_second_match_available": "Kein zweites Spiel verfügbar",
"no_statistics_available": "Keine Statistiken verfügbar", "no_statistics_available": "Keine Statistiken verfügbar",
"none": "Kein", "none": "Kein",
@@ -70,11 +77,14 @@
"privacy_policy": "Datenschutzerklärung", "privacy_policy": "Datenschutzerklärung",
"quick_create": "Schnellzugriff", "quick_create": "Schnellzugriff",
"recent_matches": "Letzte Spiele", "recent_matches": "Letzte Spiele",
"result": "Ergebnis",
"results": "Ergebnisse",
"ruleset": "Regelwerk", "ruleset": "Regelwerk",
"ruleset_least_points": "Umgekehrte Wertung: Der/die Spieler:in mit den wenigsten Punkten gewinnt.", "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_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_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.", "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_groups": "Nach Gruppen suchen",
"search_for_players": "Nach Spieler:innen suchen", "search_for_players": "Nach Spieler:innen suchen",
"select_winner": "Gewinner:in wählen:", "select_winner": "Gewinner:in wählen:",

View File

@@ -37,10 +37,10 @@
"description": "Button text to create a match" "description": "Button text to create a match"
}, },
"@create_new_group": { "@create_new_group": {
"description": "Button text to create a new group" "description": "Appbar text to create a new group"
}, },
"@create_new_match": { "@create_new_match": {
"description": "Button text to create a new match" "description": "Appbar text to create a new match"
}, },
"@created_on": { "@created_on": {
"description": "Label for creation date" "description": "Label for creation date"
@@ -72,14 +72,29 @@
"description": "Confirmation dialog for deleting all data" "description": "Confirmation dialog for deleting all data"
}, },
"@delete_group": { "@delete_group": {
"description": "Button text to delete a group" "description": "Confirmation dialog for deleting a group"
},
"@delete_match": {
"description": "Button text to delete a match"
}, },
"@edit_group": { "@edit_group": {
"description": "Button text to edit a 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": { "@error_creating_group": {
"description": "Error message when group creation fails" "description": "Error message when group creation fails"
}, },
"@error_deleting_group": {
"description": "Error message when group deletion fails"
},
"@error_editing_group": {
"description": "Error message when group editing fails"
},
"@error_reading_file": { "@error_reading_file": {
"description": "Error message when file cannot be read" "description": "Error message when file cannot be read"
}, },
@@ -143,6 +158,9 @@
"@match_name": { "@match_name": {
"description": "Placeholder for match name input" "description": "Placeholder for match name input"
}, },
"@match_profile": {
"description": "Title for match profile view"
},
"@matches": { "@matches": {
"description": "Label for matches" "description": "Label for matches"
}, },
@@ -179,6 +197,9 @@
"@no_recent_matches_available": { "@no_recent_matches_available": {
"description": "Message when no recent matches exist" "description": "Message when no recent matches exist"
}, },
"@no_results_entered_yet": {
"description": "Message when no results have been entered yet"
},
"@no_second_match_available": { "@no_second_match_available": {
"description": "Message when no second match exists" "description": "Message when no second match exists"
}, },
@@ -220,6 +241,9 @@
"@recent_matches": { "@recent_matches": {
"description": "Title for recent matches section" "description": "Title for recent matches section"
}, },
"@results": {
"description": "Label for match results"
},
"@ruleset": { "@ruleset": {
"description": "Ruleset label" "description": "Ruleset label"
}, },
@@ -235,6 +259,9 @@
"@ruleset_single_winner": { "@ruleset_single_winner": {
"description": "Description for single winner ruleset" "description": "Description for single winner ruleset"
}, },
"@save_changes": {
"description": "Save changes button text"
},
"@search_for_groups": { "@search_for_groups": {
"description": "Hint text for group search input field" "description": "Hint text for group search input field"
}, },
@@ -321,8 +348,13 @@
"delete": "Delete", "delete": "Delete",
"delete_all_data": "Delete all data", "delete_all_data": "Delete all data",
"delete_group": "Delete Group", "delete_group": "Delete Group",
"delete_match": "Delete Match",
"edit_group": "Edit Group", "edit_group": "Edit Group",
"edit_match": "Edit Match",
"enter_results": "Enter Results",
"error_creating_group": "Error while creating group, please try again", "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",
"error_reading_file": "Error reading file", "error_reading_file": "Error reading file",
"export_canceled": "Export canceled", "export_canceled": "Export canceled",
"export_data": "Export data", "export_data": "Export data",
@@ -344,6 +376,7 @@
"licenses": "Licenses", "licenses": "Licenses",
"match_in_progress": "Match in progress...", "match_in_progress": "Match in progress...",
"match_name": "Match name", "match_name": "Match name",
"match_profile": "Match Profile",
"matches": "Matches", "matches": "Matches",
"members": "Members", "members": "Members",
"most_points": "Most Points", "most_points": "Most Points",
@@ -356,6 +389,7 @@
"no_players_found_with_that_name": "No players found with that name", "no_players_found_with_that_name": "No players found with that name",
"no_players_selected": "No players selected", "no_players_selected": "No players selected",
"no_recent_matches_available": "No recent matches available", "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_second_match_available": "No second match available",
"no_statistics_available": "No statistics available", "no_statistics_available": "No statistics available",
"none": "None", "none": "None",
@@ -368,11 +402,13 @@
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",
"quick_create": "Quick Create", "quick_create": "Quick Create",
"recent_matches": "Recent Matches", "recent_matches": "Recent Matches",
"results": "Results",
"ruleset": "Ruleset", "ruleset": "Ruleset",
"ruleset_least_points": "Inverse scoring: the player with the fewest points wins.", "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_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_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.", "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_groups": "Search for groups",
"search_for_players": "Search for players", "search_for_players": "Search for players",
"select_winner": "Select Winner:", "select_winner": "Select Winner:",

View File

@@ -170,7 +170,7 @@ abstract class AppLocalizations {
/// **'Create match'** /// **'Create match'**
String get create_match; String get create_match;
/// Button text to create a new group /// Appbar text to create a new group
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Create new group'** /// **'Create new group'**
@@ -182,7 +182,7 @@ abstract class AppLocalizations {
/// **'Created on'** /// **'Created on'**
String get created_on; String get created_on;
/// Button text to create a new match /// Appbar text to create a new match
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Create new match'** /// **'Create new match'**
@@ -230,24 +230,54 @@ abstract class AppLocalizations {
/// **'Delete all data'** /// **'Delete all data'**
String get delete_all_data; String get delete_all_data;
/// Button text to delete a group /// Confirmation dialog for deleting a group
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Delete Group'** /// **'Delete Group'**
String get delete_group; String get delete_group;
/// Button text to edit a 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: /// In en, this message translates to:
/// **'Edit Group'** /// **'Edit Group'**
String get 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 /// Error message when group creation fails
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Error while creating group, please try again'** /// **'Error while creating group, please try again'**
String get error_creating_group; String get error_creating_group;
/// Error message when group deletion fails
///
/// In en, this message translates to:
/// **'Error while deleting group, please try again'**
String get error_deleting_group;
/// Error message when group editing fails
///
/// In en, this message translates to:
/// **'Error while editing group, please try again'**
String get error_editing_group;
/// Error message when file cannot be read /// Error message when file cannot be read
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -374,6 +404,12 @@ abstract class AppLocalizations {
/// **'Match name'** /// **'Match name'**
String get 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 /// Label for matches
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -446,6 +482,12 @@ abstract class AppLocalizations {
/// **'No recent matches available'** /// **'No recent matches available'**
String get 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 /// Message when no second match exists
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -518,6 +560,12 @@ abstract class AppLocalizations {
/// **'Recent Matches'** /// **'Recent Matches'**
String get recent_matches; String get recent_matches;
/// Label for match results
///
/// In en, this message translates to:
/// **'Results'**
String get results;
/// Ruleset label /// Ruleset label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -548,6 +596,12 @@ abstract class AppLocalizations {
/// **'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'** /// **'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'**
String get ruleset_single_winner; 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 /// Hint text for group search input field
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -79,15 +79,32 @@ class AppLocalizationsDe extends AppLocalizations {
String get delete_all_data => 'Alle Daten löschen'; String get delete_all_data => 'Alle Daten löschen';
@override @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 @override
String get edit_group => 'Gruppe bearbeiten'; String get edit_group => 'Gruppe bearbeiten';
@override
String get edit_match => 'Gruppe bearbeiten';
@override
String get enter_results => 'Ergebnisse eintragen';
@override @override
String get error_creating_group => String get error_creating_group =>
'Fehler beim Erstellen der Gruppe, bitte erneut versuchen'; 'Fehler beim Erstellen der Gruppe, bitte erneut versuchen';
@override
String get error_deleting_group =>
'Fehler beim Löschen der Gruppe, bitte erneut versuchen';
@override
String get error_editing_group =>
'Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen';
@override @override
String get error_reading_file => 'Fehler beim Lesen der Datei'; String get error_reading_file => 'Fehler beim Lesen der Datei';
@@ -151,6 +168,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get match_name => 'Spieltitel'; String get match_name => 'Spieltitel';
@override
String get match_profile => 'Spielprofil';
@override @override
String get matches => 'Spiele'; String get matches => 'Spiele';
@@ -188,6 +208,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get no_recent_matches_available => 'Keine letzten Spiele verfügbar'; String get no_recent_matches_available => 'Keine letzten Spiele verfügbar';
@override
String get no_results_entered_yet => 'Noch keine Ergebnisse eingetragen';
@override @override
String get no_second_match_available => 'Kein zweites Spiel verfügbar'; String get no_second_match_available => 'Kein zweites Spiel verfügbar';
@@ -226,6 +249,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recent_matches => 'Letzte Spiele'; String get recent_matches => 'Letzte Spiele';
@override
String get results => 'Ergebnisse';
@override @override
String get ruleset => 'Regelwerk'; String get ruleset => 'Regelwerk';
@@ -245,6 +271,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get ruleset_single_winner => String get ruleset_single_winner =>
'Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.'; '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 @override
String get search_for_groups => 'Nach Gruppen suchen'; String get search_for_groups => 'Nach Gruppen suchen';

View File

@@ -81,13 +81,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get delete_group => 'Delete Group'; String get delete_group => 'Delete Group';
@override
String get delete_match => 'Delete Match';
@override @override
String get edit_group => 'Edit Group'; String get edit_group => 'Edit Group';
@override
String get edit_match => 'Edit Match';
@override
String get enter_results => 'Enter Results';
@override @override
String get error_creating_group => String get error_creating_group =>
'Error while creating group, please try again'; 'Error while creating group, please try again';
@override
String get error_deleting_group =>
'Error while deleting group, please try again';
@override
String get error_editing_group =>
'Error while editing group, please try again';
@override @override
String get error_reading_file => 'Error reading file'; String get error_reading_file => 'Error reading file';
@@ -151,6 +168,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get match_name => 'Match name'; String get match_name => 'Match name';
@override
String get match_profile => 'Match Profile';
@override @override
String get matches => 'Matches'; String get matches => 'Matches';
@@ -188,6 +208,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get no_recent_matches_available => 'No recent matches available'; String get no_recent_matches_available => 'No recent matches available';
@override
String get no_results_entered_yet => 'No results entered yet';
@override @override
String get no_second_match_available => 'No second match available'; String get no_second_match_available => 'No second match available';
@@ -226,6 +249,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recent_matches => 'Recent Matches'; String get recent_matches => 'Recent Matches';
@override
String get results => 'Results';
@override @override
String get ruleset => 'Ruleset'; String get ruleset => 'Ruleset';
@@ -245,6 +271,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get ruleset_single_winner => String get ruleset_single_winner =>
'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.'; 'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.';
@override
String get save_changes => 'Save Changes';
@override @override
String get search_for_groups => 'Search for groups'; String get search_for_groups => 'Search for groups';

View File

@@ -29,19 +29,31 @@ class GameTracker extends StatelessWidget {
return supportedLocale; return supportedLocale;
} }
} }
return supportedLocales.firstWhere((locale) => locale.languageCode == 'en'); return supportedLocales.firstWhere(
(locale) => locale.languageCode == 'en',
);
}, },
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
onGenerateTitle: (context) => AppLocalizations.of(context).app_name, onGenerateTitle: (context) => AppLocalizations.of(context).app_name,
themeMode: ThemeMode.dark, // forces dark mode themeMode: ThemeMode.dark,
theme: ThemeData( theme: ThemeData(
// main colors
primaryColor: CustomTheme.primaryColor, primaryColor: CustomTheme.primaryColor,
scaffoldBackgroundColor: CustomTheme.backgroundColor, scaffoldBackgroundColor: CustomTheme.backgroundColor,
// themes
appBarTheme: CustomTheme.appBarTheme, appBarTheme: CustomTheme.appBarTheme,
inputDecorationTheme: CustomTheme.inputDecorationTheme,
searchBarTheme: CustomTheme.searchBarTheme,
radioTheme: CustomTheme.radioTheme,
// color scheme
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: CustomTheme.primaryColor, seedColor: CustomTheme.textColor,
brightness: Brightness.dark, brightness: Brightness.dark,
).copyWith(surface: CustomTheme.backgroundColor), primary: CustomTheme.primaryColor,
onPrimary: CustomTheme.textColor,
surface: CustomTheme.backgroundColor,
onSurface: CustomTheme.textColor,
),
pageTransitionsTheme: const PageTransitionsTheme( pageTransitionsTheme: const PageTransitionsTheme(
builders: { builders: {
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/group_view/groups_view.dart'; import 'package:tallee/presentation/views/main_menu/group_view/group_view.dart';
import 'package:tallee/presentation/views/main_menu/home_view.dart'; import 'package:tallee/presentation/views/main_menu/home_view.dart';
import 'package:tallee/presentation/views/main_menu/match_view/match_view.dart'; import 'package:tallee/presentation/views/main_menu/match_view/match_view.dart';
import 'package:tallee/presentation/views/main_menu/settings_view/settings_view.dart'; import 'package:tallee/presentation/views/main_menu/settings_view/settings_view.dart';
@@ -38,7 +38,7 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
), ),
KeyedSubtree( KeyedSubtree(
key: ValueKey('groups_$tabKeyCount'), key: ValueKey('groups_$tabKeyCount'),
child: const GroupsView(), child: const GroupView(),
), ),
KeyedSubtree( KeyedSubtree(
key: ValueKey('stats_$tabKeyCount'), key: ValueKey('stats_$tabKeyCount'),
@@ -141,7 +141,7 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
} }
/// Returns the title of the current tab based on [currentIndex]. /// Returns the title of the current tab based on [currentIndex].
String _currentTabTitle(context) { String _currentTabTitle(BuildContext context) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
switch (currentIndex) { switch (currentIndex) {
case 0: case 0:

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart'; import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/db/database.dart';
@@ -12,8 +11,10 @@ import 'package:tallee/presentation/widgets/player_selection.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
class CreateGroupView extends StatefulWidget { class CreateGroupView extends StatefulWidget {
/// A view that allows the user to create a new group const CreateGroupView({super.key, this.groupToEdit});
const CreateGroupView({super.key});
/// The group to edit, if any
final Group? groupToEdit;
@override @override
State<CreateGroupView> createState() => _CreateGroupViewState(); State<CreateGroupView> createState() => _CreateGroupViewState();
@@ -22,16 +23,29 @@ class CreateGroupView extends StatefulWidget {
class _CreateGroupViewState extends State<CreateGroupView> { class _CreateGroupViewState extends State<CreateGroupView> {
late final AppDatabase db; late final AppDatabase db;
/// GlobalKey for ScaffoldMessenger to show snackbars
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
/// Controller for the group name input field /// Controller for the group name input field
final _groupNameController = TextEditingController(); final _groupNameController = TextEditingController();
/// List of currently selected players /// List of currently selected players
List<Player> selectedPlayers = []; List<Player> selectedPlayers = [];
/// List of initially selected players (when editing a group)
List<Player> initialSelectedPlayers = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
db = Provider.of<AppDatabase>(context, listen: false); db = Provider.of<AppDatabase>(context, listen: false);
if (widget.groupToEdit != null) {
_groupNameController.text = widget.groupToEdit!.name;
setState(() {
initialSelectedPlayers = widget.groupToEdit!.members;
selectedPlayers = widget.groupToEdit!.members;
});
}
_groupNameController.addListener(() { _groupNameController.addListener(() {
setState(() {}); setState(() {});
}); });
@@ -47,10 +61,58 @@ class _CreateGroupViewState extends State<CreateGroupView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
return ScaffoldMessenger( return ScaffoldMessenger(
key: _scaffoldMessengerKey,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
backgroundColor: CustomTheme.backgroundColor, backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(title: Text(loc.create_new_group)), appBar: AppBar(
title: Text(
widget.groupToEdit == null ? loc.create_new_group : loc.edit_group,
),
actions: widget.groupToEdit == null
? []
: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
if (widget.groupToEdit != null) {
showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.delete_group),
content: Text(loc.this_cannot_be_undone),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: Text(loc.cancel),
),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
child: Text(loc.delete),
),
],
),
).then((confirmed) async {
if (confirmed == true && context.mounted) {
bool success = await db.groupDao.deleteGroup(
groupId: widget.groupToEdit!.id,
);
if (!context.mounted) return;
if (success) {
Navigator.pop(context);
} else {
if (!mounted) return;
showSnackbar(message: loc.error_deleting_group);
}
}
});
}
},
),
],
),
body: SafeArea( body: SafeArea(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@@ -60,11 +122,11 @@ class _CreateGroupViewState extends State<CreateGroupView> {
child: TextInputField( child: TextInputField(
controller: _groupNameController, controller: _groupNameController,
hintText: loc.group_name, hintText: loc.group_name,
maxLength: Constants.MAX_GROUP_NAME_LENGTH,
), ),
), ),
Expanded( Expanded(
child: PlayerSelection( child: PlayerSelection(
initialSelectedPlayers: initialSelectedPlayers,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
selectedPlayers = [...value]; selectedPlayers = [...value];
@@ -73,7 +135,9 @@ class _CreateGroupViewState extends State<CreateGroupView> {
), ),
), ),
CustomWidthButton( CustomWidthButton(
text: loc.create_group, text: widget.groupToEdit == null
? loc.create_group
: loc.edit_group,
sizeRelativeToWidth: 0.95, sizeRelativeToWidth: 0.95,
buttonType: ButtonType.primary, buttonType: ButtonType.primary,
onPressed: onPressed:
@@ -81,29 +145,38 @@ class _CreateGroupViewState extends State<CreateGroupView> {
(selectedPlayers.length < 2)) (selectedPlayers.length < 2))
? null ? null
: () async { : () async {
bool success = await db.groupDao.addGroup( late Group? updatedGroup;
late bool success;
if (widget.groupToEdit == null) {
success = await db.groupDao.addGroup(
group: Group( group: Group(
name: _groupNameController.text.trim(), name: _groupNameController.text.trim(),
description: '',
members: selectedPlayers, members: selectedPlayers,
), ),
); );
} else {
updatedGroup = Group(
id: widget.groupToEdit!.id,
name: _groupNameController.text.trim(),
description: '',
members: selectedPlayers,
);
//TODO: Implement group editing in database
/*
success = await db.groupDao.updateGroup(
group: updatedGroup,
);
*/
success = true;
}
if (!context.mounted) return; if (!context.mounted) return;
if (success) { if (success) {
Navigator.pop(context); Navigator.pop(context, updatedGroup);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( showSnackbar(
SnackBar( message: widget.groupToEdit == null
backgroundColor: CustomTheme.boxColor, ? loc.error_creating_group
content: Center( : loc.error_editing_group,
child: Text(
AppLocalizations.of(
context,
).error_creating_group,
style: const TextStyle(color: Colors.white),
),
),
),
); );
} }
}, },
@@ -115,4 +188,20 @@ class _CreateGroupViewState extends State<CreateGroupView> {
), ),
); );
} }
/// Displays a snackbar with the given message and optional action.
///
/// [message] The message to display in the snackbar.
void showSnackbar({required String message}) {
final messenger = _scaffoldMessengerKey.currentState;
if (messenger != null) {
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: CustomTheme.boxColor,
),
);
}
}
} }

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/db/database.dart'; import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/dto/group.dart'; import 'package:tallee/data/dto/group.dart';
import 'package:tallee/data/dto/match.dart'; import 'package:tallee/data/dto/match.dart';
import 'package:tallee/data/dto/player.dart'; import 'package:tallee/data/dto/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/group_view/create_group_view.dart';
import 'package:tallee/presentation/widgets/app_skeleton.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart';
import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.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/buttons/main_menu_button.dart';
@@ -15,10 +17,10 @@ import 'package:tallee/presentation/widgets/custom_alert_dialog.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class GroupProfileView extends StatefulWidget { class GroupDetailView extends StatefulWidget {
/// A view that displays the profile of a group /// A view that displays the profile of a group
/// - [group]: The group to display /// - [group]: The group to display
const GroupProfileView({ const GroupDetailView({
super.key, super.key,
required this.group, required this.group,
required this.callback, required this.callback,
@@ -30,21 +32,24 @@ class GroupProfileView extends StatefulWidget {
final VoidCallback callback; final VoidCallback callback;
@override @override
State<GroupProfileView> createState() => _GroupProfileViewState(); State<GroupDetailView> createState() => _GroupDetailViewState();
} }
class _GroupProfileViewState extends State<GroupProfileView> { class _GroupDetailViewState extends State<GroupDetailView> {
late final AppDatabase db; late final AppDatabase db;
bool isLoading = true; bool isLoading = true;
late Group _group;
/// Total matches played in this group /// Total matches played in this group
int totalMatches = 0; int totalMatches = 0;
/// The best player in this group
String bestPlayer = ''; String bestPlayer = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_group = widget.group;
db = Provider.of<AppDatabase>(context, listen: false); db = Provider.of<AppDatabase>(context, listen: false);
_loadStatistics(); _loadStatistics();
} }
@@ -87,7 +92,7 @@ class _GroupProfileViewState extends State<GroupProfileView> {
), ),
).then((confirmed) async { ).then((confirmed) async {
if (confirmed! && context.mounted) { if (confirmed! && context.mounted) {
await db.groupDao.deleteGroup(groupId: widget.group.id); await db.groupDao.deleteGroup(groupId: _group.id);
if (!context.mounted) return; if (!context.mounted) return;
Navigator.pop(context); Navigator.pop(context);
widget.callback.call(); widget.callback.call();
@@ -118,7 +123,7 @@ class _GroupProfileViewState extends State<GroupProfileView> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
widget.group.name, _group.name,
style: const TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -128,7 +133,7 @@ class _GroupProfileViewState extends State<GroupProfileView> {
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
Text( Text(
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(widget.group.createdAt)}', '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(_group.createdAt)}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: CustomTheme.textColor, color: CustomTheme.textColor,
@@ -145,7 +150,7 @@ class _GroupProfileViewState extends State<GroupProfileView> {
crossAxisAlignment: WrapCrossAlignment.start, crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: widget.group.members.map((member) { children: _group.members.map((member) {
return TextIconTile( return TextIconTile(
text: member.name, text: member.name,
iconEnabled: false, iconEnabled: false,
@@ -163,7 +168,7 @@ class _GroupProfileViewState extends State<GroupProfileView> {
children: [ children: [
_buildStatRow( _buildStatRow(
loc.members, loc.members,
widget.group.members.length.toString(), _group.members.length.toString(),
), ),
_buildStatRow( _buildStatRow(
loc.played_matches, loc.played_matches,
@@ -181,19 +186,22 @@ class _GroupProfileViewState extends State<GroupProfileView> {
child: MainMenuButton( child: MainMenuButton(
text: loc.edit_group, text: loc.edit_group,
icon: Icons.edit, icon: Icons.edit,
onPressed: () { onPressed: () async {
// TODO: Uncomment when GroupDetailView is implemented final updatedGroup = await Navigator.push<Group?>(
/*
await Navigator.push(
context, context,
adaptivePageRoute( adaptivePageRoute(
builder: (context) { builder: (context) {
return CreateGroupView(groupToEdit: _group);
return const GroupDetailView();
}, },
), ),
);*/ );
print('Edit Group pressed'); if (updatedGroup != null && mounted) {
setState(() {
_group = updatedGroup;
});
_loadStatistics();
widget.callback();
}
}, },
), ),
), ),
@@ -236,7 +244,7 @@ class _GroupProfileViewState extends State<GroupProfileView> {
Future<void> _loadStatistics() async { Future<void> _loadStatistics() async {
final matches = await db.matchDao.getAllMatches(); final matches = await db.matchDao.getAllMatches();
final groupMatches = matches final groupMatches = matches
.where((match) => match.group?.id == widget.group.id) .where((match) => match.group?.id == _group.id)
.toList(); .toList();
setState(() { setState(() {

View File

@@ -8,21 +8,21 @@ import 'package:tallee/data/dto/group.dart';
import 'package:tallee/data/dto/player.dart'; import 'package:tallee/data/dto/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/group_view/create_group_view.dart'; import 'package:tallee/presentation/views/main_menu/group_view/create_group_view.dart';
import 'package:tallee/presentation/views/main_menu/group_view/group_profile_view.dart'; import 'package:tallee/presentation/views/main_menu/group_view/group_detail_view.dart';
import 'package:tallee/presentation/widgets/app_skeleton.dart'; import 'package:tallee/presentation/widgets/app_skeleton.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/tiles/group_tile.dart'; import 'package:tallee/presentation/widgets/tiles/group_tile.dart';
import 'package:tallee/presentation/widgets/top_centered_message.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 /// A view that displays a list of groups
const GroupsView({super.key}); const GroupView({super.key});
@override @override
State<GroupsView> createState() => _GroupsViewState(); State<GroupView> createState() => _GroupViewState();
} }
class _GroupsViewState extends State<GroupsView> { class _GroupViewState extends State<GroupView> {
late final AppDatabase db; late final AppDatabase db;
/// Loaded groups from the database /// Loaded groups from the database
@@ -83,7 +83,7 @@ class _GroupsViewState extends State<GroupsView> {
context, context,
adaptivePageRoute( adaptivePageRoute(
builder: (context) { builder: (context) {
return GroupProfileView( return GroupDetailView(
group: groups[index], group: groups[index],
callback: loadGroups, callback: loadGroups,
); );

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart'; import 'package:tallee/core/enums.dart';
import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/l10n/generated/app_localizations.dart';

View File

@@ -21,11 +21,22 @@ import 'package:tallee/presentation/widgets/tiles/choose_tile.dart';
class CreateMatchView extends StatefulWidget { class CreateMatchView extends StatefulWidget {
/// A view that allows creating a new match /// A view that allows creating a new match
/// [onWinnerChanged]: Optional callback invoked when the winner is changed /// [onWinnerChanged]: Optional callback invoked when the winner is changed
const CreateMatchView({super.key, this.onWinnerChanged}); const CreateMatchView({
super.key,
this.onWinnerChanged,
this.matchToEdit,
this.onMatchUpdated,
});
/// Optional callback invoked when the winner is changed /// Optional callback invoked when the winner is changed
final VoidCallback? onWinnerChanged; 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 @override
State<CreateMatchView> createState() => _CreateMatchViewState(); State<CreateMatchView> createState() => _CreateMatchViewState();
} }
@@ -45,19 +56,9 @@ class _CreateMatchViewState extends State<CreateMatchView> {
/// List of all players from the database /// List of all players from the database
List<Player> playerList = []; List<Player> 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<Player> filteredPlayerList = [];
/// The currently selected group /// The currently selected group
Group? selectedGroup; 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 index of the currently selected game in [games] to mark it in
/// the [ChooseGameView] /// the [ChooseGameView]
int selectedGameIndex = -1; int selectedGameIndex = -1;
@@ -65,6 +66,9 @@ class _CreateMatchViewState extends State<CreateMatchView> {
/// The currently selected players /// The currently selected players
List<Player> selectedPlayers = []; List<Player> selectedPlayers = [];
/// GlobalKey for ScaffoldMessenger to show snackbars
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -80,9 +84,11 @@ class _CreateMatchViewState extends State<CreateMatchView> {
]).then((result) async { ]).then((result) async {
groupsList = result[0] as List<Group>; groupsList = result[0] as List<Group>;
playerList = result[1] as List<Player>; playerList = result[1] as List<Player>;
setState(() {
filteredPlayerList = List.from(playerList); // If a match is provided, prefill the fields
}); if (widget.matchToEdit != null) {
prefillMatchDetails();
}
}); });
} }
@@ -107,11 +113,19 @@ class _CreateMatchViewState extends State<CreateMatchView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(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( return ScaffoldMessenger(
key: _scaffoldMessengerKey,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
backgroundColor: CustomTheme.backgroundColor, backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(title: Text(loc.create_new_match)), appBar: AppBar(title: Text(viewTitle)),
body: SafeArea( body: SafeArea(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@@ -153,95 +167,54 @@ class _CreateMatchViewState extends State<CreateMatchView> {
? loc.none_group ? loc.none_group
: selectedGroup!.name, : selectedGroup!.name,
onPressed: () async { 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( selectedGroup = await Navigator.of(context).push(
adaptivePageRoute( adaptivePageRoute(
builder: (context) => ChooseGroupView( builder: (context) => ChooseGroupView(
groups: groupsList, groups: groupsList,
initialGroupId: selectedGroupId, initialGroupId: selectedGroup?.id ?? '',
), ),
), ),
); );
selectedGroupId = selectedGroup?.id ?? '';
setState(() {
if (selectedGroup != null) { if (selectedGroup != null) {
filteredPlayerList = playerList setState(() {
.where( selectedPlayers += [...selectedGroup!.members];
(p) => });
!selectedGroup!.members.any((m) => m.id == p.id),
)
.toList();
} else {
filteredPlayerList = List.from(playerList);
} }
setState(() {}); });
}, },
), ),
Expanded( Expanded(
child: PlayerSelection( child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'), key: ValueKey(selectedGroup?.id ?? 'no_group'),
initialSelectedPlayers: selectedPlayers, initialSelectedPlayers: selectedPlayers,
availablePlayers: filteredPlayerList,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
selectedPlayers = value; selectedPlayers = value;
removeGroupWhenNoMemberLeft();
}); });
}, },
), ),
), ),
CustomWidthButton( CustomWidthButton(
text: loc.create_match, text: buttonText,
sizeRelativeToWidth: 0.95, sizeRelativeToWidth: 0.95,
buttonType: ButtonType.primary, buttonType: ButtonType.primary,
onPressed: _enableCreateGameButton() onPressed: _enableCreateGameButton()
? () async { ? () {
// Use a game from the games list buttonNavigation(context);
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,
),
),
);
}
} }
: null, : null,
), ),
@@ -259,6 +232,155 @@ class _CreateMatchViewState extends State<CreateMatchView> {
/// - Either a group is selected OR at least 2 players are selected /// - Either a group is selected OR at least 2 players are selected
bool _enableCreateGameButton() { bool _enableCreateGameButton() {
return (selectedGroup != null || return (selectedGroup != null ||
(selectedPlayers.length > 1)); (selectedPlayers.length > 1) && selectedGameIndex != -1);
}
// 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
flixcoo marked this conversation as resolved
Review

sollte nicht auch hier als bedingung rein, dass game ausgewählt ist (auch wenns gerade noch keine funktion hat)

sollte nicht auch hier als bedingung rein, dass game ausgewählt ist (auch wenns gerade noch keine funktion hat)
Review

Geändert

Geändert
// 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<void> 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<Match> 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<Game> 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<void> removeGroupWhenNoMemberLeft() async {
if (selectedGroup == null) return;
if (!selectedPlayers.any(
(player) =>
selectedGroup!.members.any((member) => member.id == player.id),
)) {
setState(() {
selectedGroup = null;
});
}
} }
} }

View File

@@ -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<MatchDetailView> createState() => _MatchDetailViewState();
}
class _MatchDetailViewState extends State<MatchDetailView> {
late final AppDatabase db;
late Match match;
@override
void initState() {
super.initState();
db = Provider.of<AppDatabase>(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<bool>(
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();
}
}

View File

@@ -35,7 +35,10 @@ class _MatchResultViewState extends State<MatchResultView> {
@override @override
void initState() { void initState() {
db = Provider.of<AppDatabase>(context, listen: false); db = Provider.of<AppDatabase>(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) { if (widget.match.winner != null) {
_selectedPlayer = allPlayers.firstWhere( _selectedPlayer = allPlayers.firstWhere(
(p) => p.id == widget.match.winner!.id, (p) => p.id == widget.match.winner!.id,
@@ -54,7 +57,7 @@ class _MatchResultViewState extends State<MatchResultView> {
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
widget.onWinnerChanged?.call(); widget.onWinnerChanged?.call();
Navigator.of(context).pop(); Navigator.of(context).pop(_selectedPlayer);
}, },
), ),
title: Text(widget.match.name), title: Text(widget.match.name),
@@ -145,21 +148,4 @@ class _MatchResultViewState extends State<MatchResultView> {
} }
widget.onWinnerChanged?.call(); 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<Player> getAllPlayers(Match match) {
List<Player> 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;
}
} }

View File

@@ -1,5 +1,3 @@
import 'dart:core' hide Match;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttericon/rpg_awesome_icons.dart'; import 'package:fluttericon/rpg_awesome_icons.dart';
import 'package:provider/provider.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/data/dto/player.dart';
import 'package:tallee/l10n/generated/app_localizations.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/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/app_skeleton.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/tiles/match_tile.dart'; import 'package:tallee/presentation/widgets/tiles/match_tile.dart';
@@ -38,7 +36,13 @@ class _MatchViewState extends State<MatchView> {
4, 4,
Match( Match(
name: 'Skeleton match name', 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( group: Group(
name: 'Group name', name: 'Group name',
description: '', description: '',
@@ -54,7 +58,7 @@ class _MatchViewState extends State<MatchView> {
void initState() { void initState() {
super.initState(); super.initState();
db = Provider.of<AppDatabase>(context, listen: false); db = Provider.of<AppDatabase>(context, listen: false);
loadGames(); loadMatches();
} }
@override @override
@@ -94,10 +98,9 @@ class _MatchViewState extends State<MatchView> {
Navigator.push( Navigator.push(
context, context,
adaptivePageRoute( adaptivePageRoute(
fullscreenDialog: true, builder: (context) => MatchDetailView(
builder: (context) => MatchResultView(
match: matches[index], match: matches[index],
onWinnerChanged: loadGames, onMatchUpdate: loadMatches,
), ),
), ),
); );
@@ -120,7 +123,7 @@ class _MatchViewState extends State<MatchView> {
context, context,
adaptivePageRoute( adaptivePageRoute(
builder: (context) => builder: (context) =>
CreateMatchView(onWinnerChanged: loadGames), CreateMatchView(onWinnerChanged: loadMatches),
), ),
); );
}, },
@@ -131,8 +134,9 @@ class _MatchViewState extends State<MatchView> {
); );
} }
/// Loads the games from the database and sorts them by creation date. /// Loads the matches from the database and sorts them by creation date.
void loadGames() { void loadMatches() {
isLoading = true;
Future.wait([ Future.wait([
db.matchDao.getAllMatches(), db.matchDao.getAllMatches(),
Future.delayed(Constants.MINIMUM_SKELETON_DURATION), Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
@@ -140,7 +144,7 @@ class _MatchViewState extends State<MatchView> {
if (mounted) { if (mounted) {
setState(() { setState(() {
final loadedMatches = results[0] as List<Match>; final loadedMatches = results[0] as List<Match>;
matches = loadedMatches matches = [...loadedMatches]
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
isLoading = false; isLoading = false;
}); });

View File

@@ -31,6 +31,7 @@ class _SettingsViewState extends State<SettingsView> {
version: 'n.A.', version: 'n.A.',
buildNumber: 'n.A.', buildNumber: 'n.A.',
); );
@override @override
void initState() { void initState() {
super.initState(); super.initState();

View File

@@ -47,10 +47,7 @@ class _AppSkeletonState extends State<AppSkeleton> {
: (Widget? currentChild, List<Widget> previousChildren) { : (Widget? currentChild, List<Widget> previousChildren) {
return Stack( return Stack(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
children: [ children: [...previousChildren, ?currentChild],
flixcoo marked this conversation as resolved
Review

was macht das?

was macht das?
Review

Das ist ein Null-aware Spread Operator, d.h. das Element currentChild wird nur in die Liste eingefügt, wenn es nicht null ist. Der Ausdruck ist Äquivalent zu

children: [
   ...previousChildren,
   if (currentChild != null) currentChild,
],

Siehe Discord-Nachricht bzw. Dart Documentation

Das ist ein Null-aware Spread Operator, d.h. das Element `currentChild` wird nur in die Liste eingefügt, wenn es nicht `null` ist. Der Ausdruck ist Äquivalent zu ```dart children: [ ...previousChildren, if (currentChild != null) currentChild, ], ``` Siehe [Discord-Nachricht](https://discord.com/channels/921763177245605888/922566373455048875/1479868438812950661) bzw. [Dart Documentation](https://dart.dev/language/collections#null-aware-element)
...previousChildren,
if (currentChild != null) currentChild,
],
); );
}, },
), ),

View File

@@ -2,25 +2,25 @@ import 'package:flutter/material.dart';
class MainMenuButton extends StatefulWidget { class MainMenuButton extends StatefulWidget {
/// A button for the main menu with an optional icon and a press animation. /// 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. /// - [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({ const MainMenuButton({
super.key, super.key,
required this.text,
this.icon,
required this.onPressed, 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. /// 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;
/// The text of the button.
final String? text;
@override @override
State<MainMenuButton> createState() => _MainMenuButtonState(); State<MainMenuButton> createState() => _MainMenuButtonState();
} }
@@ -71,12 +71,11 @@ class _MainMenuButtonState extends State<MainMenuButton>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(width: 7),
],
Text( Text(
widget.text, widget.text!,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -84,6 +83,7 @@ class _MainMenuButtonState extends State<MainMenuButton>
), ),
), ),
], ],
],
), ),
), ),
), ),

View File

@@ -70,6 +70,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
super.initState(); super.initState();
db = Provider.of<AppDatabase>(context, listen: false); db = Provider.of<AppDatabase>(context, listen: false);
suggestedPlayers = skeletonData; suggestedPlayers = skeletonData;
selectedPlayers = widget.initialSelectedPlayers ?? [];
loadPlayerList(); loadPlayerList();
} }
@@ -100,7 +101,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
if (value.isEmpty) { if (value.isEmpty) {
// If the search is empty, it shows all unselected players. // If the search is empty, it shows all unselected players.
suggestedPlayers = allPlayers.where((player) { suggestedPlayers = allPlayers.where((player) {
return !selectedPlayers.contains(player); return !selectedPlayers.any((p) => p.id == player.id);
}).toList(); }).toList();
} else { } else {
// If there is input, it filters by name match (case-insensitive) and ensures // If there is input, it filters by name match (case-insensitive) and ensures
@@ -109,8 +110,8 @@ class _PlayerSelectionState extends State<PlayerSelection> {
final bool nameMatches = player.name.toLowerCase().contains( final bool nameMatches = player.name.toLowerCase().contains(
value.toLowerCase(), value.toLowerCase(),
); );
final bool isNotSelected = !selectedPlayers.contains( final bool isNotSelected = !selectedPlayers.any(
player, (p) => p.id == player.id,
); );
return nameMatches && isNotSelected; return nameMatches && isNotSelected;
}).toList(); }).toList();
@@ -126,6 +127,8 @@ class _PlayerSelectionState extends State<PlayerSelection> {
const SizedBox(height: 10), const SizedBox(height: 10),
SizedBox( SizedBox(
height: 50, height: 50,
child: AppSkeleton(
enabled: isLoading,
child: selectedPlayers.isEmpty child: selectedPlayers.isEmpty
? Center(child: Text(loc.no_players_selected)) ? Center(child: Text(loc.no_players_selected))
: SingleChildScrollView( : SingleChildScrollView(
@@ -167,6 +170,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
), ),
), ),
), ),
),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
loc.all_players, loc.all_players,
@@ -222,8 +226,9 @@ class _PlayerSelectionState extends State<PlayerSelection> {
db.playerDao.getAllPlayers(), db.playerDao.getAllPlayers(),
Future.delayed(Constants.MINIMUM_SKELETON_DURATION), Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
]).then((results) => results[0] as List<Player>); ]).then((results) => results[0] as List<Player>);
if (mounted) {
_allPlayersFuture.then((loadedPlayers) { _allPlayersFuture.then((loadedPlayers) {
if (!mounted) return;
setState(() { setState(() {
// If a list of available players is provided (even if empty), use that list. // If a list of available players is provided (even if empty), use that list.
if (widget.availablePlayers != null) { if (widget.availablePlayers != null) {
@@ -245,13 +250,30 @@ class _PlayerSelectionState extends State<PlayerSelection> {
// Otherwise, use the loaded players from the database. // Otherwise, use the loaded players from the database.
loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); loadedPlayers.sort((a, b) => a.name.compareTo(b.name));
allPlayers = [...loadedPlayers]; 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),
flixcoo marked this conversation as resolved
Review

was geht hier ab? wieso changes?

was geht hier ab? wieso changes?
Review

Da kam dieser Error, den du hattest her. Da habe ich ein if statement entfernt, dadurch hat sich alles verschoben und wird als Änderung markiert. Effektiv habe ich nur ein if(mounted){...} durch ein if(!mounted) return; ersetzt

Da kam dieser Error, den du hattest her. Da habe ich ein if statement entfernt, dadurch hat sich alles verschoben und wird als Änderung markiert. Effektiv habe ich nur ein `if(mounted){...}` durch ein `if(!mounted) return;` ersetzt
)
.toList();
} else {
// If no initial selection, all loaded players are suggested.
suggestedPlayers = [...loadedPlayers]; suggestedPlayers = [...loadedPlayers];
} }
}
isLoading = false; isLoading = false;
}); });
}); });
} }
}
/// Adds a new player to the database from the search bar input. /// Adds a new player to the database from the search bar input.
/// Shows a snackbar indicating success or failure. /// Shows a snackbar indicating success or failure.

View File

@@ -69,7 +69,6 @@ class CustomSearchBar extends StatelessWidget {
constraints ?? const BoxConstraints(maxHeight: 45, minHeight: 45), constraints ?? const BoxConstraints(maxHeight: 45, minHeight: 45),
hintText: hintText, hintText: hintText,
onChanged: onChanged, onChanged: onChanged,
hintStyle: WidgetStateProperty.all(const TextStyle(fontSize: 16)),
leading: const Icon(Icons.search), leading: const Icon(Icons.search),
trailing: [ trailing: [
Visibility( Visibility(

View File

@@ -36,11 +36,7 @@ class CustomRadioListTile<T> extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Radio<T>( Radio<T>(value: value, toggleable: true),
value: value,
activeColor: CustomTheme.primaryColor,
toggleable: true,
),
Expanded( Expanded(
child: Text( child: Text(
text, text,

View File

@@ -1,8 +1,10 @@
import 'dart:core' hide Match;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/dto/match.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/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -38,18 +40,13 @@ class MatchTile extends StatefulWidget {
} }
class _MatchTileState extends State<MatchTile> { class _MatchTileState extends State<MatchTile> {
late final List<Player> _allPlayers;
@override
void initState() {
super.initState();
_allPlayers = _getCombinedPlayers();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final group = widget.match.group; final match = widget.match;
final winner = widget.match.winner; 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); final loc = AppLocalizations.of(context);
return GestureDetector( return GestureDetector(
@@ -67,7 +64,7 @@ class _MatchTileState extends State<MatchTile> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
widget.match.name, match.name,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -76,7 +73,7 @@ class _MatchTileState extends State<MatchTile> {
), ),
), ),
Text( Text(
_formatDate(widget.match.createdAt, context), _formatDate(match.createdAt, context),
style: const TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
), ),
], ],
@@ -91,7 +88,7 @@ class _MatchTileState extends State<MatchTile> {
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
'${group.name} + ${widget.match.players.length}', '${match.group!.name}${getExtraPlayerCount(match)}',
style: const TextStyle(fontSize: 14, color: Colors.grey), style: const TextStyle(fontSize: 14, color: Colors.grey),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -106,7 +103,7 @@ class _MatchTileState extends State<MatchTile> {
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
'${widget.match.players.length} ${loc.players}', '${match.players.length} ${loc.players}',
style: const TextStyle(fontSize: 14, color: Colors.grey), style: const TextStyle(fontSize: 14, color: Colors.grey),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -192,7 +189,7 @@ class _MatchTileState extends State<MatchTile> {
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
if (_allPlayers.isNotEmpty && widget.compact == false) ...[ if (players.isNotEmpty && widget.compact == false) ...[
Text( Text(
loc.players, loc.players,
style: const TextStyle( style: const TextStyle(
@@ -205,7 +202,7 @@ class _MatchTileState extends State<MatchTile> {
Wrap( Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
children: _allPlayers.map((player) { children: players.map((player) {
return TextIconTile(text: player.name, iconEnabled: false); return TextIconTile(text: player.name, iconEnabled: false);
}).toList(), }).toList(),
), ),
@@ -233,32 +230,4 @@ class _MatchTileState extends State<MatchTile> {
return '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(dateTime)}'; 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<Player> _getCombinedPlayers() {
final allPlayers = <Player>[];
final playerIds = <String>{};
// 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;
}
} }

View File

@@ -34,7 +34,7 @@ dev_dependencies:
build_runner: ^2.5.4 build_runner: ^2.5.4
dart_pubspec_licenses: ^3.0.14 dart_pubspec_licenses: ^3.0.14
drift_dev: ^2.27.0 drift_dev: ^2.27.0
flutter_lints: ^5.0.0 flutter_lints: ^6.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -1,13 +1,13 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart' hide isNotNull;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.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/db/database.dart';
import 'package:tallee/data/dto/game.dart'; import 'package:tallee/data/dto/game.dart';
import 'package:tallee/data/dto/group.dart'; import 'package:tallee/data/dto/group.dart';
import 'package:tallee/data/dto/match.dart'; import 'package:tallee/data/dto/match.dart';
import 'package:tallee/data/dto/player.dart'; import 'package:tallee/data/dto/player.dart';
import 'package:tallee/core/enums.dart';
void main() { void main() {
late AppDatabase database; late AppDatabase database;
@@ -51,7 +51,13 @@ void main() {
description: '', description: '',
members: [testPlayer4, testPlayer5], 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( testMatch1 = Match(
name: 'First Test Match', name: 'First Test Match',
game: testGame, game: testGame,
@@ -99,7 +105,6 @@ void main() {
}); });
group('Match Tests', () { group('Match Tests', () {
// Verifies that a single match can be added and retrieved with all fields, group, and players intact. // 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 { test('Adding and fetching single match works correctly', () async {
await database.matchDao.addMatch(match: testMatch1); await database.matchDao.addMatch(match: testMatch1);
@@ -127,10 +132,7 @@ void main() {
for (int i = 0; i < testMatch1.players.length; i++) { for (int i = 0; i < testMatch1.players.length; i++) {
expect(result.players[i].id, testMatch1.players[i].id); expect(result.players[i].id, testMatch1.players[i].id);
expect(result.players[i].name, testMatch1.players[i].name); expect(result.players[i].name, testMatch1.players[i].name);
expect( expect(result.players[i].createdAt, testMatch1.players[i].createdAt);
result.players[i].createdAt,
testMatch1.players[i].createdAt,
);
} }
}); });
@@ -191,10 +193,7 @@ void main() {
for (int i = 0; i < testMatch.players.length; i++) { for (int i = 0; i < testMatch.players.length; i++) {
expect(match.players[i].id, testMatch.players[i].id); expect(match.players[i].id, testMatch.players[i].id);
expect(match.players[i].name, testMatch.players[i].name); expect(match.players[i].name, testMatch.players[i].name);
expect( expect(match.players[i].createdAt, testMatch.players[i].createdAt);
match.players[i].createdAt,
testMatch.players[i].createdAt,
);
} }
} }
}); });
@@ -282,5 +281,31 @@ void main() {
); );
expect(fetchedMatch.name, newName); 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);
});
}); });
} }