58 Commits

Author SHA1 Message Date
eaf7822732 Revert to 4bd2f97
All checks were successful
Pull Request Pipeline / lint (pull_request) Successful in 45s
Pull Request Pipeline / localizations (pull_request) Successful in 29s
Pull Request Pipeline / test (pull_request) Successful in 1m31s
2026-05-25 19:22:59 +02:00
6679a0f942 Updated licenses [skip ci] 2026-05-25 12:56:12 +00:00
bf6c352d54 Updated version number [skip ci] 2026-05-25 12:55:35 +00:00
9b208f4780 Revert "Merge branch 'feature/193-statisticsview-rework' into development"
All checks were successful
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 38s
Push Pipeline / generate_localizations (push) Successful in 29s
Push Pipeline / test (push) Successful in 1m35s
Push Pipeline / sort_arb_files (push) Successful in 31s
Push Pipeline / format (push) Successful in 55s
Push Pipeline / build (push) Successful in 4m58s
This reverts commit 24f49e17b9, reversing
changes made to dba6c218d6.

# Conflicts:
#	pubspec.yaml
2026-05-25 14:55:19 +02:00
5659dc36c2 Updated licenses [skip ci] 2026-05-25 12:52:27 +00:00
712d48b1d7 Updated version number [skip ci] 2026-05-25 12:51:48 +00:00
24f49e17b9 Merge branch 'feature/193-statisticsview-rework' into development
Some checks failed
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 40s
Push Pipeline / generate_localizations (push) Successful in 30s
Push Pipeline / test (push) Successful in 1m34s
Push Pipeline / sort_arb_files (push) Failing after 32s
Push Pipeline / format (push) Has been skipped
Push Pipeline / build (push) Has been skipped
# Conflicts:
#	pubspec.lock
#	pubspec.yaml
2026-05-25 14:51:36 +02:00
dba6c218d6 Updated licenses [skip ci] 2026-05-25 12:12:46 +00:00
82325ea271 Updated version number [skip ci] 2026-05-25 12:12:09 +00:00
f7973a4bc2 fix: build job
All checks were successful
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 37s
Push Pipeline / generate_localizations (push) Successful in 28s
Push Pipeline / test (push) Successful in 1m32s
Push Pipeline / sort_arb_files (push) Successful in 31s
Push Pipeline / format (push) Successful in 54s
Push Pipeline / build (push) Successful in 4m55s
2026-05-25 14:12:01 +02:00
258e668a5e Updated licenses [skip ci] 2026-05-25 12:08:58 +00:00
a951f3c9b2 Updated version number [skip ci] 2026-05-25 12:08:20 +00:00
ad5cd98327 fix: build job
Some checks failed
Push Pipeline / update_version (push) Successful in 5s
Push Pipeline / generate_licenses (push) Successful in 39s
Push Pipeline / generate_localizations (push) Successful in 28s
Push Pipeline / test (push) Successful in 1m32s
Push Pipeline / sort_arb_files (push) Successful in 30s
Push Pipeline / format (push) Successful in 53s
Push Pipeline / build (push) Failing after 14s
2026-05-25 14:08:10 +02:00
250c647fb2 Updated licenses [skip ci] 2026-05-25 12:04:06 +00:00
e7f904296d Updated version number [skip ci] 2026-05-25 12:03:30 +00:00
362ab2a945 Updated test job
Some checks failed
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 38s
Push Pipeline / generate_localizations (push) Successful in 28s
Push Pipeline / test (push) Successful in 1m33s
Push Pipeline / sort_arb_files (push) Successful in 30s
Push Pipeline / format (push) Successful in 54s
Push Pipeline / build (push) Failing after 20s
2026-05-25 14:03:18 +02:00
d4a67f4086 Updated licenses [skip ci] 2026-05-25 12:01:43 +00:00
9fe74c291c Updated version number [skip ci] 2026-05-25 12:01:06 +00:00
84bb8ccccc fix(deps): update dart dependencies (non-major) (#248)
Some checks failed
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 39s
Push Pipeline / test (push) Failing after 47s
Push Pipeline / generate_localizations (push) Successful in 28s
Push Pipeline / sort_arb_files (push) Successful in 33s
Push Pipeline / format (push) Successful in 53s
Push Pipeline / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [build_runner](https://github.com/dart-lang/build) ([source](https://github.com/dart-lang/build/tree/HEAD/build_runner)) | dev_dependencies | minor | `^2.7.0` -> `^2.15.0` |
| [cupertino_icons](https://github.com/flutter/packages) ([source](https://github.com/flutter/packages/tree/HEAD/third_party/packages/cupertino_icons)) | dependencies | patch | `^1.0.6` -> `^1.0.9` |
| [dart](https://dart.dev/) ([source](https://github.com/dart-lang/sdk)) |  | minor | `^3.8.1` -> `^3.12.0` |
| [dart_pubspec_licenses](https://github.com/espresso3389/flutter_oss_licenses/tree/master/packages/dart_pubspec_licenses) ([source](https://github.com/espresso3389/flutter_oss_licenses)) | dev_dependencies | minor | `^3.0.14` -> `^3.2.0` |
| [drift](https://drift.simonbinder.eu/) ([source](https://github.com/simolus3/drift)) | dependencies | minor | `^2.27.0` -> `^2.33.0` |
| [drift_dev](https://drift.simonbinder.eu/) ([source](https://github.com/simolus3/drift)) | dev_dependencies | minor | `^2.27.0` -> `^2.33.0` |
| [drift_flutter](https://drift.simonbinder.eu/) ([source](https://github.com/simolus3/drift)) | dependencies | minor | `^0.2.4` -> `^0.3.0` |
| [file_saver](https://hassanansari.dev) ([source](https://github.com/incrediblezayed/file_saver)) | dependencies | minor | `^0.3.1` -> `^0.4.0` |
| [package_info_plus](https://github.com/fluttercommunity/plus_plugins) ([source](https://github.com/fluttercommunity/plus_plugins/tree/HEAD/packages/package_info_plus/package_info_plus)) | dependencies | patch | `^9.0.0` -> `^9.0.1` |
| [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) | dependencies | patch | `^2.1.0+1` -> `^2.1.3` |
| [uuid](https://github.com/Daegalus/dart-uuid) | dependencies | patch | `^4.5.2` -> `^4.5.3` |

>  **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOS4yNjQuMSIsInVwZGF0ZWRJblZlciI6IjM5LjI2NC4xIiwidGFyZ2V0QnJhbmNoIjoiZGV2ZWxvcG1lbnQiLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: Felix Kirchner <felix.kirchner.fk@gmail.com>
Reviewed-on: #248
Co-authored-by: Gitea Actions <actions@yannick-weigert.de>
Co-committed-by: Gitea Actions <actions@yannick-weigert.de>
2026-05-25 12:00:57 +00:00
e1d0eb4bd4 Merge remote-tracking branch 'origin/feature/193-statisticsview-rework' into feature/193-statisticsview-rework
All checks were successful
Pull Request Pipeline / lint (pull_request) Successful in 46s
Pull Request Pipeline / test (pull_request) Successful in 48s
Pull Request Pipeline / localizations (pull_request) Successful in 26s
# Conflicts:
#	lib/l10n/arb/app_de.arb
#	lib/l10n/arb/app_en.arb
#	lib/l10n/generated/app_localizations_de.dart
#	lib/l10n/generated/app_localizations_en.dart
2026-05-25 13:25:09 +02:00
4bd2f972df fix: localizations 2026-05-25 13:24:23 +02:00
730341dc7e fix: localizations
All checks were successful
Pull Request Pipeline / lint (pull_request) Successful in 47s
Pull Request Pipeline / test (pull_request) Successful in 49s
Pull Request Pipeline / localizations (pull_request) Successful in 28s
2026-05-25 13:17:02 +02:00
fb2f6d3adc feat: dynamic display count shown in tile
Some checks failed
Pull Request Pipeline / lint (pull_request) Successful in 47s
Pull Request Pipeline / test (pull_request) Successful in 49s
Pull Request Pipeline / localizations (pull_request) Failing after 29s
2026-05-25 13:06:37 +02:00
b9710ed851 fix: pixel overflow 2026-05-25 12:55:36 +02:00
efd1097d5a feat: changing display count 2026-05-25 12:51:24 +02:00
bfb40d2eab feat: statistic detail view
Some checks failed
Pull Request Pipeline / lint (pull_request) Failing after 47s
Pull Request Pipeline / test (pull_request) Successful in 48s
Pull Request Pipeline / localizations (pull_request) Failing after 27s
2026-05-25 00:39:01 +02:00
72442b5375 fix: added delete function 2026-05-24 23:34:53 +02:00
bccd47e20e Refactoring 2026-05-24 23:27:14 +02:00
428f967010 feat: displayCount 2026-05-24 23:09:08 +02:00
f65ea09cbe Added spacing 2026-05-24 17:33:41 +02:00
ffd52055fa fixed bar length 2026-05-24 17:28:29 +02:00
398c7a4168 Refactoring 2026-05-24 17:07:09 +02:00
d82206319a Renamed GameColor -> AppColor 2026-05-24 17:04:14 +02:00
5a2cc790dd Renamed GameColor -> AppColor 2026-05-24 17:03:58 +02:00
18a5dcfdd5 Changed colors 2026-05-24 17:03:43 +02:00
2e3b462533 feat: added statistic tile factory
Some checks failed
Pull Request Pipeline / lint (pull_request) Failing after 47s
Pull Request Pipeline / test (pull_request) Successful in 49s
Pull Request Pipeline / localizations (pull_request) Successful in 27s
2026-05-24 15:11:56 +02:00
807ae61df7 feat: basic database functionality
Some checks failed
Pull Request Pipeline / lint (pull_request) Failing after 47s
Pull Request Pipeline / test (pull_request) Successful in 49s
Pull Request Pipeline / localizations (pull_request) Successful in 27s
2026-05-24 13:52:27 +02:00
37031d66c9 Updated attribute order 2026-05-24 12:16:44 +02:00
d389b93cc5 Updated method with join 2026-05-24 12:16:36 +02:00
134f77c5a3 feat: create statistics view
All checks were successful
Pull Request Pipeline / lint (pull_request) Successful in 48s
Pull Request Pipeline / test (pull_request) Successful in 49s
Pull Request Pipeline / localizations (pull_request) Successful in 27s
2026-05-24 01:26:08 +02:00
57ebea1eb7 Merge branch 'feature/180-Spielerprofile-implementieren' into feature/193-statisticsview-rework 2026-05-24 01:10:45 +02:00
9ad50c9f9c Removed bg color 2026-05-23 21:52:32 +02:00
1f17b80f64 Merge branch 'development' into feature/180-Spielerprofile-implementieren
All checks were successful
Pull Request Pipeline / lint (pull_request) Successful in 51s
Pull Request Pipeline / localizations (pull_request) Successful in 28s
Pull Request Pipeline / test (pull_request) Successful in 49s
2026-05-23 00:42:59 +02:00
fad5a392cd Merge branch 'development' into feature/180-Spielerprofile-implementieren
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 46s
Pull Request Pipeline / lint (pull_request) Successful in 53s
Pull Request Pipeline / localizations (pull_request) Successful in 25s
# Conflicts:
#	lib/l10n/arb/app_de.arb
#	lib/l10n/arb/app_en.arb
#	lib/l10n/generated/app_localizations.dart
#	lib/l10n/generated/app_localizations_de.dart
#	lib/l10n/generated/app_localizations_en.dart
2026-05-23 00:22:03 +02:00
5a652a5f2c feat: updatePlayerName keeps created order in nameCount
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 46s
Pull Request Pipeline / lint (pull_request) Successful in 54s
2026-05-22 20:06:27 +02:00
4dcd4f0f71 fix: name count 3 player issue 2026-05-22 20:00:28 +02:00
25bc213769 Merge remote-tracking branch 'origin/feature/180-Spielerprofile-implementieren' into feature/180-Spielerprofile-implementieren
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 47s
Pull Request Pipeline / lint (pull_request) Successful in 54s
2026-05-22 08:45:43 +02:00
9adcc29cda fix: updatePlayerName corrects the name count after renaming to different name
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 47s
Pull Request Pipeline / lint (pull_request) Successful in 55s
2026-05-21 23:57:59 +02:00
78c59a9b52 feat: add localization for no matches played and not part of any group 2026-05-21 16:20:48 +02:00
bf2cd2bf58 feat: add player creation callbacks to update member and match lists when group/match creation is canceled but player created
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 46s
Pull Request Pipeline / lint (pull_request) Successful in 55s
2026-05-21 16:08:59 +02:00
ccb0d32c54 fix: tests for name count
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 46s
Pull Request Pipeline / lint (pull_request) Successful in 54s
2026-05-21 15:45:29 +02:00
82095ab41a fix: name count 2026-05-21 15:33:26 +02:00
2a38462c57 fix linter issues
Some checks failed
Pull Request Pipeline / test (pull_request) Failing after 44s
Pull Request Pipeline / lint (pull_request) Successful in 56s
2026-05-21 10:34:01 +02:00
9909d959b0 implement missing localization
Some checks failed
Pull Request Pipeline / test (pull_request) Failing after 47s
Pull Request Pipeline / lint (pull_request) Failing after 56s
2026-05-21 10:33:16 +02:00
b61a93328f made alertDialog Confirm Button deactivate based on input, fix app skeleton alignment issue, implement correct nameCount Display
Some checks failed
Pull Request Pipeline / test (pull_request) Failing after 42s
Pull Request Pipeline / lint (pull_request) Failing after 51s
2026-05-21 09:47:49 +02:00
679e869229 fix: player count calc error
Some checks failed
Pull Request Pipeline / test (pull_request) Failing after 44s
Pull Request Pipeline / lint (pull_request) Failing after 52s
2026-05-20 19:58:59 +02:00
869c70ff63 add player change callbacks and improve player detail view
Some checks failed
Pull Request Pipeline / test (pull_request) Failing after 47s
Pull Request Pipeline / lint (pull_request) Failing after 53s
2026-05-20 19:50:34 +02:00
b305145d34 implement basic player_detail_view.dart
Some checks failed
Pull Request Pipeline / test (pull_request) Successful in 45s
Pull Request Pipeline / lint (pull_request) Failing after 51s
2026-05-20 15:15:47 +02:00
90 changed files with 7892 additions and 3861 deletions

View File

@@ -31,20 +31,16 @@ jobs:
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
- name: Checkout code
uses: actions/checkout@v4
# Required for Flutter action
- name: Install jq
- name: Install Node
run: |
apt-get update
apt-get install -y jq
apt-get install -y nodejs npm
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Checkout code
uses: actions/checkout@v4
- name: Get dependencies
run: |

View File

@@ -7,22 +7,19 @@ on:
- "main"
jobs:
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
- name: Checkout code
uses: actions/checkout@v4
# Required for Flutter action
- name: Install jq
- name: Install Node
run: |
apt-get update
apt-get install -y jq
apt-get install -y nodejs npm
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Checkout code
uses: actions/checkout@v4
- name: Get dependencies
run: |
@@ -295,6 +292,8 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: "platform-tools platforms;android-34 build-tools;34.0.0"
# Required for Flutter action
- name: Install jq

View File

@@ -166,9 +166,6 @@
"notes": {
"type": "string"
},
"isTeamMatch": {
"type": "boolean"
},
"teams": {
"type": ["array", "null"]
}
@@ -180,8 +177,7 @@
"createdAt",
"gameId",
"playerIds",
"notes",
"isTeamMatch"
"notes"
]
}
}

View File

@@ -1,13 +1,5 @@
{
"pins" : [
{
"identity" : "csqlite",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simolus3/CSQLite.git",
"state" : {
"revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",

View File

@@ -24,23 +24,8 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) {
}
}
// Returns a AppColor enum value based on the provided team [index].
AppColor getTeamColor(int index) {
final colors = [
AppColor.red,
AppColor.blue,
AppColor.green,
AppColor.yellow,
AppColor.purple,
AppColor.orange,
AppColor.pink,
AppColor.teal,
];
return colors[index % colors.length];
}
/// Translates a [AppColor] enum value to its corresponding localized string.
String translateGameColorToString(AppColor color, BuildContext context) {
String translateAppColorToString(AppColor color, BuildContext context) {
final loc = AppLocalizations.of(context);
switch (color) {
case AppColor.red:
@@ -63,7 +48,7 @@ String translateGameColorToString(AppColor color, BuildContext context) {
}
/// Returns the [Color] object corresponding to a [AppColor] enum value.
Color getColorFromGameColor(AppColor color) {
Color getColorFromAppColor(AppColor color) {
switch (color) {
case AppColor.red:
return Colors.red;
@@ -78,9 +63,9 @@ Color getColorFromGameColor(AppColor color) {
case AppColor.orange:
return const Color(0xFFef681f);
case AppColor.pink:
return Colors.pink;
return const Color(0xFFE91E63);
case AppColor.teal:
return Colors.teal;
return const Color(0xFF00BCD4);
}
}
@@ -92,10 +77,11 @@ IconData getRulesetIcon(Ruleset ruleset) {
case Ruleset.lowestScore:
return Icons.arrow_downward;
case Ruleset.singleWinner:
case Ruleset.multipleWinners:
return Icons.emoji_events;
case Ruleset.singleLoser:
return Icons.sentiment_dissatisfied;
case Ruleset.multipleWinners:
return Icons.group;
case Ruleset.placement:
return RpgAwesome.podium;
}
@@ -127,7 +113,6 @@ String getExtraPlayerCount(Match match) {
return ' + ${count.toString()}';
}
/// Returns the player name count if greater 0 in the format " #2", otherwise an empty string
String getNameCountText(Player player) {
if (player.nameCount >= 1) {
return ' #${player.nameCount}';
@@ -135,7 +120,6 @@ String getNameCountText(Player player) {
return '';
}
/// Returns the correct singular or plural form of "point(s)" based on the [points] value.
String getPointLabel(AppLocalizations loc, int points) {
if (points == 1) {
return '$points ${loc.point}';

View File

@@ -65,11 +65,7 @@ class CustomTheme {
static BoxDecoration highlightedBoxDecoration = BoxDecoration(
color: boxColor,
border: Border.all(
color: textColor,
width: 2,
strokeAlign: BorderSide.strokeAlignCenter,
),
border: Border.all(color: textColor, width: 2),
borderRadius: standardBorderRadiusAll,
);

View File

@@ -42,5 +42,33 @@ enum Ruleset {
singleLoser,
}
/// Different colors for highlighting content
/// Different colors for highlighting games
enum AppColor { red, orange, yellow, green, teal, blue, purple, pink }
enum StatisticType {
totalMatches,
totalWins,
totalScore,
totalLosses,
averageScore,
bestScore,
worstScore,
winrate,
}
enum StatisticScope {
allPlayers,
//selectedPlayer,
selectedGroups,
selectedGames,
timeframe,
}
enum Timeframe {
last7Days,
last30Days,
last90Days,
last180Days,
lastYear,
allTime,
}

View File

@@ -77,8 +77,8 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
/// Returns `true` if the game exists, `false` otherwise.
Future<bool> gameExists({required String gameId}) async {
final query = select(gameTable)..where((g) => g.id.equals(gameId));
final result = await query.getSingleOrNull();
return result != null;
final row = await query.getSingleOrNull();
return row != null;
}
/// Retrieves all games from the database.
@@ -103,15 +103,15 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
/// Retrieves a [Game] by its [gameId].
Future<Game> getGameById({required String gameId}) async {
final query = select(gameTable)..where((g) => g.id.equals(gameId));
final result = await query.getSingle();
final row = await query.getSingle();
return Game(
id: result.id,
name: result.name,
ruleset: Ruleset.values.firstWhere((e) => e.name == result.ruleset),
description: result.description,
color: AppColor.values.firstWhere((e) => e.name == result.color),
icon: result.icon,
createdAt: result.createdAt,
id: row.id,
name: row.name,
ruleset: Ruleset.values.firstWhere((e) => e.name == row.ruleset),
description: row.description,
color: AppColor.values.firstWhere((e) => e.name == row.color),
icon: row.icon,
createdAt: row.createdAt,
);
}
@@ -123,7 +123,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
required String name,
}) async {
final rowsAffected =
await (update(gameTable)..where((g) => g.id.equals(gameId))).write(
await (update(gameTable)..where((tbl) => tbl.id.equals(gameId))).write(
GameTableCompanion(name: Value(name)),
);
return rowsAffected > 0;
@@ -135,7 +135,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
required Ruleset ruleset,
}) async {
final rowsAffected =
await (update(gameTable)..where((g) => g.id.equals(gameId))).write(
await (update(gameTable)..where((tbl) => tbl.id.equals(gameId))).write(
GameTableCompanion(ruleset: Value(ruleset.name)),
);
return rowsAffected > 0;
@@ -147,7 +147,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
required String description,
}) async {
final rowsAffected =
await (update(gameTable)..where((g) => g.id.equals(gameId))).write(
await (update(gameTable)..where((tbl) => tbl.id.equals(gameId))).write(
GameTableCompanion(description: Value(description)),
);
return rowsAffected > 0;
@@ -159,7 +159,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
required AppColor color,
}) async {
final rowsAffected =
await (update(gameTable)..where((g) => g.id.equals(gameId))).write(
await (update(gameTable)..where((tbl) => tbl.id.equals(gameId))).write(
GameTableCompanion(color: Value(color.name)),
);
return rowsAffected > 0;
@@ -171,7 +171,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
required String icon,
}) async {
final rowsAffected =
await (update(gameTable)..where((g) => g.id.equals(gameId))).write(
await (update(gameTable)..where((tbl) => tbl.id.equals(gameId))).write(
GameTableCompanion(icon: Value(icon)),
);
return rowsAffected > 0;
@@ -182,7 +182,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
/// Deletes the game with the given [gameId] from the database.
/// Returns `true` if the game was deleted, `false` if the game did not exist.
Future<bool> deleteGame({required String gameId}) async {
final query = delete(gameTable)..where((g) => g.id.equals(gameId));
final query = delete(gameTable)..where((tbl) => tbl.id.equals(gameId));
final rowsAffected = await query.go();
return rowsAffected > 0;
}

View File

@@ -143,16 +143,16 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
final query = select(groupTable);
final result = await query.get();
return Future.wait(
result.map((groupData) async {
result.map((row) async {
final members = await db.playerGroupDao.getPlayersOfGroup(
groupId: groupData.id,
groupId: row.id,
);
return Group(
id: groupData.id,
name: groupData.name,
description: groupData.description,
id: row.id,
name: row.name,
description: row.description,
members: members,
createdAt: groupData.createdAt,
createdAt: row.createdAt,
);
}),
);
@@ -161,18 +161,18 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
/// Retrieves a [Group] by its [groupId], including its members.
Future<Group> getGroupById({required String groupId}) async {
final query = select(groupTable)..where((g) => g.id.equals(groupId));
final result = await query.getSingle();
final row = await query.getSingle();
List<Player> members = await db.playerGroupDao.getPlayersOfGroup(
groupId: groupId,
);
return Group(
id: result.id,
name: result.name,
description: result.description,
id: row.id,
name: row.name,
description: row.description,
members: members,
createdAt: result.createdAt,
createdAt: row.createdAt,
);
}
@@ -180,17 +180,49 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
Future<int> getGroupCount() async {
final count =
await (selectOnly(groupTable)..addColumns([groupTable.id.count()]))
.map((row) => row.read(groupTable.id.count()))
.map((tbl) => tbl.read(groupTable.id.count()))
.getSingle();
return count ?? 0;
}
/// Retrieves all groups a specific player belongs to.
/// Returns an empty list if the player is not part of any group.
Future<List<Group>> getGroupsByPlayer({required String playerId}) async {
final playerGroups = await (select(
playerGroupTable,
)..where((tbl) => tbl.playerId.equals(playerId))).get();
if (playerGroups.isEmpty) return [];
final groupIds = playerGroups.map((pg) => pg.groupId).toSet().toList();
final result =
await (select(groupTable)
..where((tbl) => tbl.id.isIn(groupIds))
..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]))
.get();
return Future.wait(
result.map((row) async {
final members = await db.playerGroupDao.getPlayersOfGroup(
groupId: row.id,
);
return Group(
id: row.id,
name: row.name,
description: row.description,
members: members,
createdAt: row.createdAt,
);
}),
);
}
/// Checks if a group with the given [groupId] exists in the database.
/// Returns `true` if the group exists, `false` otherwise.
Future<bool> groupExists({required String groupId}) async {
final query = select(groupTable)..where((g) => g.id.equals(groupId));
final result = await query.getSingleOrNull();
return result != null;
final row = await query.getSingleOrNull();
return row != null;
}
/* Delete */
@@ -220,9 +252,8 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
required String name,
}) async {
final rowsAffected =
await (update(groupTable)..where((g) => g.id.equals(groupId))).write(
GroupTableCompanion(name: Value(name)),
);
await (update(groupTable)..where((tbl) => tbl.id.equals(groupId)))
.write(GroupTableCompanion(name: Value(name)));
return rowsAffected > 0;
}
@@ -233,9 +264,8 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
required String description,
}) async {
final rowsAffected =
await (update(groupTable)..where((g) => g.id.equals(groupId))).write(
GroupTableCompanion(description: Value(description)),
);
await (update(groupTable)..where((tbl) => tbl.id.equals(groupId)))
.write(GroupTableCompanion(description: Value(description)));
return rowsAffected > 0;
}
}

View File

@@ -30,7 +30,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
gameId: match.game.id,
groupId: Value(match.group?.id),
name: match.name,
isTeamMatch: Value(match.isTeamMatch),
notes: match.notes,
createdAt: match.createdAt,
endedAt: Value(match.endedAt),
@@ -143,7 +142,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
gameId: match.game.id,
groupId: Value(match.group?.id),
name: match.name,
isTeamMatch: Value(match.isTeamMatch),
notes: match.notes,
createdAt: match.createdAt,
endedAt: Value(match.endedAt),
@@ -260,15 +258,15 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Returns `true` if the match exists, otherwise `false`.
Future<bool> matchExists({required String matchId}) async {
final query = select(matchTable)..where((g) => g.id.equals(matchId));
final result = await query.getSingleOrNull();
return result != null;
final row = await query.getSingleOrNull();
return row != null;
}
/// Retrieves the number of matches in the database.
Future<int> getMatchCount() async {
final count =
await (selectOnly(matchTable)..addColumns([matchTable.id.count()]))
.map((row) => row.read(matchTable.id.count()))
.map((tbl) => tbl.read(matchTable.id.count()))
.getSingle();
return count ?? 0;
}
@@ -281,10 +279,12 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
return Future.wait(
result.map((row) async {
final game = await db.gameDao.getGameById(gameId: row.gameId);
Group? group;
if (row.groupId != null) {
group = await db.groupDao.getGroupById(groupId: row.groupId!);
}
final players = await db.playerMatchDao.getPlayersOfMatch(
matchId: row.id,
);
@@ -302,7 +302,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
group: group,
players: players,
teams: teams.isEmpty ? null : teams,
isTeamMatch: row.isTeamMatch,
notes: row.notes,
createdAt: row.createdAt,
endedAt: row.endedAt,
@@ -315,13 +314,13 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Retrieves a [Match] by its [matchId].
Future<Match> getMatchById({required String matchId}) async {
final query = select(matchTable)..where((g) => g.id.equals(matchId));
final result = await query.getSingle();
final row = await query.getSingle();
final game = await db.gameDao.getGameById(gameId: result.gameId);
final game = await db.gameDao.getGameById(gameId: row.gameId);
Group? group;
if (result.groupId != null) {
group = await db.groupDao.getGroupById(groupId: result.groupId!);
if (row.groupId != null) {
group = await db.groupDao.getGroupById(groupId: row.groupId!);
}
final players = await db.playerMatchDao.getPlayersOfMatch(matchId: matchId);
@@ -331,16 +330,15 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
final teams = await _getMatchTeams(matchId: matchId);
return Match(
id: result.id,
name: result.name,
id: row.id,
name: row.name,
game: game,
group: group,
players: players,
teams: teams.isEmpty ? null : teams,
isTeamMatch: result.isTeamMatch,
notes: result.notes,
createdAt: result.createdAt,
endedAt: result.endedAt,
notes: row.notes,
createdAt: row.createdAt,
endedAt: row.endedAt,
scores: scores,
);
}
@@ -351,25 +349,73 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
await (selectOnly(matchTable)
..where(matchTable.gameId.equals(gameId))
..addColumns([matchTable.id.count()]))
.map((row) => row.read(matchTable.id.count()))
.map((tbl) => tbl.read(matchTable.id.count()))
.getSingle();
return count ?? 0;
}
/// Retrieves all matches associated with the given [groupId].
/// Queries the database directly, filtering by [groupId].
Future<List<Match>> getMatchesByGroup({required String groupId}) async {
final query = select(matchTable)..where((m) => m.groupId.equals(groupId));
final rows = await query.get();
Future<List<Match>> getMatchesByPlayer({required String playerId}) async {
final playerMatches = await (select(
playerMatchTable,
)..where((tbl) => tbl.playerId.equals(playerId))).get();
if (playerMatches.isEmpty) return [];
final matchIds = playerMatches.map((tbl) => tbl.matchId).toSet().toList();
final result =
await (select(matchTable)
..where((tbl) => tbl.id.isIn(matchIds))
..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]))
.get();
return Future.wait(
rows.map((row) async {
result.map((row) async {
final game = await db.gameDao.getGameById(gameId: row.gameId);
final group = await db.groupDao.getGroupById(groupId: groupId);
Group? group;
if (row.groupId != null) {
group = await db.groupDao.getGroupById(groupId: row.groupId!);
}
final players = await db.playerMatchDao.getPlayersOfMatch(
matchId: row.id,
);
final scores = await db.scoreEntryDao.getAllMatchScores(
matchId: row.id,
);
final teams = await _getMatchTeams(matchId: row.id);
return Match(
id: row.id,
name: row.name,
game: game,
group: group,
players: players,
teams: teams.isEmpty ? null : teams,
notes: row.notes,
createdAt: row.createdAt,
endedAt: row.endedAt,
scores: scores,
);
}),
);
}
/// Retrieves all matches associated with the given [groupId].
/// Queries the database directly, filtering by [groupId].
Future<List<Match>> getMatchesByGroup({required String groupId}) async {
final query = select(matchTable)..where((m) => m.groupId.equals(groupId));
final result = await query.get();
return Future.wait(
result.map((row) async {
final game = await db.gameDao.getGameById(gameId: row.gameId);
final group = await db.groupDao.getGroupById(groupId: groupId);
final players = await db.playerMatchDao.getPlayersOfMatch(
matchId: row.id,
);
final teams = await _getMatchTeams(matchId: row.id);
return Match(
id: row.id,
name: row.name,
@@ -377,7 +423,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
group: group,
players: players,
teams: teams.isEmpty ? null : teams,
isTeamMatch: row.isTeamMatch,
notes: row.notes,
createdAt: row.createdAt,
endedAt: row.endedAt,
@@ -390,7 +435,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
Future<List<Team>> _getMatchTeams({required String matchId}) async {
// Get all unique team IDs from PlayerMatchTable for this match
final playerMatchQuery = select(db.playerMatchTable)
..where((pm) => pm.matchId.equals(matchId) & pm.teamId.isNotNull());
..where((tbl) => tbl.matchId.equals(matchId) & tbl.teamId.isNotNull());
final playerMatches = await playerMatchQuery.get();
if (playerMatches.isEmpty) return [];
@@ -406,8 +451,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
teamIds.map((teamId) => db.teamDao.getTeamById(teamId: teamId)),
);
return teams
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return teams;
}
/* Update */
@@ -418,7 +462,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
required String matchId,
required String name,
}) async {
final query = update(matchTable)..where((g) => g.id.equals(matchId));
final query = update(matchTable)..where((tbl) => tbl.id.equals(matchId));
final rowsAffected = await query.write(
MatchTableCompanion(name: Value(name)),
);
@@ -433,7 +477,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
required String matchId,
required String? groupId,
}) async {
final query = update(matchTable)..where((g) => g.id.equals(matchId));
final query = update(matchTable)..where((tbl) => tbl.id.equals(matchId));
final rowsAffected = await query.write(
MatchTableCompanion(groupId: Value(groupId)),
);
@@ -446,7 +490,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
required String matchId,
required String notes,
}) async {
final query = update(matchTable)..where((g) => g.id.equals(matchId));
final query = update(matchTable)..where((tbl) => tbl.id.equals(matchId));
final rowsAffected = await query.write(
MatchTableCompanion(notes: Value(notes)),
);
@@ -457,7 +501,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Sets the groupId to null.
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> removeMatchGroup({required String matchId}) async {
final query = update(matchTable)..where((g) => g.id.equals(matchId));
final query = update(matchTable)..where((tbl) => tbl.id.equals(matchId));
final rowsAffected = await query.write(
const MatchTableCompanion(groupId: Value(null)),
);
@@ -471,7 +515,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
required String matchId,
required DateTime endedAt,
}) async {
final query = update(matchTable)..where((g) => g.id.equals(matchId));
final query = update(matchTable)..where((tbl) => tbl.id.equals(matchId));
final rowsAffected = await query.write(
MatchTableCompanion(endedAt: Value(endedAt)),
);
@@ -483,7 +527,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Deletes the match with the given [matchId] from the database.
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> deleteMatch({required String matchId}) async {
final query = delete(matchTable)..where((g) => g.id.equals(matchId));
final query = delete(matchTable)..where((tbl) => tbl.id.equals(matchId));
final rowsAffected = await query.go();
return rowsAffected > 0;
}
@@ -499,7 +543,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
/// Deletes all matches associated with a specific game.
/// Returns the number of matches deleted.
Future<int> deleteMatchesByGame({required String gameId}) async {
final query = delete(matchTable)..where((m) => m.gameId.equals(gameId));
final query = delete(matchTable)..where((tbl) => tbl.gameId.equals(gameId));
final rowsAffected = await query.go();
return rowsAffected;
}

View File

@@ -17,7 +17,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
/// the new one.
Future<bool> addPlayer({required Player player}) async {
if (!await playerExists(playerId: player.id)) {
final int nameCount = await calculateNameCount(name: player.name);
final int nameCount = await _processNameCount(name: player.name);
await into(playerTable).insert(
PlayerTableCompanion.insert(
@@ -64,7 +64,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
final playersWithName = entry.value;
// Get the current nameCount
var nameCount = await calculateNameCount(name: name);
var nameCount = await _processNameCount(name: name);
// One player with the same name
if (playersWithName.length == 1) {
@@ -113,7 +113,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
Future<int> getPlayerCount() async {
final count =
await (selectOnly(playerTable)..addColumns([playerTable.id.count()]))
.map((row) => row.read(playerTable.id.count()))
.map((tbl) => tbl.read(playerTable.id.count()))
.getSingle();
return count ?? 0;
}
@@ -122,8 +122,8 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
/// Returns `true` if the player exists, `false` otherwise.
Future<bool> playerExists({required String playerId}) async {
final query = select(playerTable)..where((p) => p.id.equals(playerId));
final result = await query.getSingleOrNull();
return result != null;
final row = await query.getSingleOrNull();
return row != null;
}
/// Retrieves all players from the database.
@@ -146,57 +146,76 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
/// Retrieves a [Player] by their [id].
Future<Player> getPlayerById({required String playerId}) async {
final query = select(playerTable)..where((p) => p.id.equals(playerId));
final result = await query.getSingle();
final row = await query.getSingle();
return Player(
id: result.id,
name: result.name,
description: result.description,
createdAt: result.createdAt,
nameCount: result.nameCount,
id: row.id,
name: row.name,
description: row.description,
createdAt: row.createdAt,
nameCount: row.nameCount,
);
}
/* Update */
/// Updates the name of the player with the given [playerId] to [name].
///
/// Keeps the `nameCount` values of the affected name groups consistent:
/// - The renamed player gets a fresh `nameCount` for the new name group.
/// - All players in the previous name group whose `nameCount` was greater
/// than the removed one get decremented by 1, so the numbering stays
/// contiguous (1..N) in `createdAt` order.
/// - If only one player remains in the previous name group, their
/// `nameCount` is reset to 0.
Future<bool> updatePlayerName({
required String playerId,
required String name,
}) async {
// Get previous name and name count for the player before updating
final previousPlayerName =
await (select(playerTable)..where((p) => p.id.equals(playerId)))
.map((row) => row.name)
.getSingleOrNull() ??
'';
final previousNameCount = await getNameCount(name: previousPlayerName);
return transaction(() async {
final previousPlayer = await (select(
playerTable,
)..where((tbl) => tbl.id.equals(playerId))).getSingleOrNull();
if (previousPlayer == null) return false;
final rowsAffected =
await (update(playerTable)..where((p) => p.id.equals(playerId))).write(
PlayerTableCompanion(name: Value(name)),
);
final previousName = previousPlayer.name;
final previousCount = previousPlayer.nameCount;
// Update name count for the new name
final count = await calculateNameCount(name: name);
if (count > 0) {
await (update(playerTable)..where((p) => p.name.equals(name))).write(
PlayerTableCompanion(nameCount: Value(count)),
);
}
// Determine the nameCount for the renamed player in the new group.
final newNameCount = await _processNameCount(name: name);
if (previousNameCount > 0) {
// Get the player with that name and the hightest nameCount, and update their nameCount to previousNameCount
final player = await getPlayerWithHighestNameCount(
name: previousPlayerName,
);
if (player != null) {
await updateNameCount(
playerId: player.id,
nameCount: previousNameCount,
);
final rowsAffected =
await (update(
playerTable,
)..where((tbl) => tbl.id.equals(playerId))).write(
PlayerTableCompanion(
name: Value(name),
nameCount: Value(newNameCount),
),
);
// Consolidate the previous name group.
final remainingCount = await getNameCount(name: previousName);
if (remainingCount == 1) {
// Only one player left
await (update(playerTable)..where((p) => p.name.equals(previousName)))
.write(const PlayerTableCompanion(nameCount: Value(0)));
} else if (remainingCount > 1 && previousCount > 0) {
// Shift every player above the gap down by one to keep numbering in order.
await (update(playerTable)..where(
(tbl) =>
tbl.name.equals(previousName) &
tbl.nameCount.isBiggerThanValue(previousCount),
))
.write(
PlayerTableCompanion.custom(
nameCount: playerTable.nameCount - const Constant(1),
),
);
}
}
return rowsAffected > 0;
return rowsAffected > 0;
});
}
/// Updates the description of the player with the given [playerId] to
@@ -207,9 +226,8 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
required String description,
}) async {
final rowsAffected =
await (update(playerTable)..where((g) => g.id.equals(playerId))).write(
PlayerTableCompanion(description: Value(description)),
);
await (update(playerTable)..where((tbl) => tbl.id.equals(playerId)))
.write(PlayerTableCompanion(description: Value(description)));
return rowsAffected > 0;
}
@@ -218,7 +236,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
/// Deletes the player with the given [id] from the database.
/// Returns `true` if the player was deleted, `false` if the player did not exist.
Future<bool> deletePlayer({required String playerId}) async {
final query = delete(playerTable)..where((p) => p.id.equals(playerId));
final query = delete(playerTable)..where((tbl) => tbl.id.equals(playerId));
final rowsAffected = await query.go();
return rowsAffected > 0;
}
@@ -226,8 +244,10 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
/* Name count management */
/// Retrieves the count of players with the given [name].
/// Returns the highest name count if players with the same name exist,
/// otherwise `null`.
Future<int> getNameCount({required String name}) async {
final query = select(playerTable)..where((p) => p.name.equals(name));
final query = select(playerTable)..where((tbl) => tbl.name.equals(name));
final result = await query.get();
return result.length;
}
@@ -238,7 +258,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
required String playerId,
required int nameCount,
}) async {
final query = update(playerTable)..where((p) => p.id.equals(playerId));
final query = update(playerTable)..where((tbl) => tbl.id.equals(playerId));
final rowsAffected = await query.write(
PlayerTableCompanion(nameCount: Value(nameCount)),
);
@@ -248,8 +268,8 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
@visibleForTesting
Future<Player?> getPlayerWithHighestNameCount({required String name}) async {
final query = select(playerTable)
..where((p) => p.name.equals(name))
..orderBy([(p) => OrderingTerm.desc(p.nameCount)])
..where((tbl) => tbl.name.equals(name))
..orderBy([(tbl) => OrderingTerm.desc(tbl.nameCount)])
..limit(1);
final result = await query.getSingleOrNull();
if (result != null) {
@@ -264,34 +284,47 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
return null;
}
/// Processes the name count for a new player with the given [name].
///- 0 Player: returning 0
///- 1 Player: returning 2, and initializes the nameCount for the existing player to 1
///- Other: returning the existing count + 1
Future<int> _processNameCount({required String name}) async {
final nameCount = await calculateNameCount(name: name);
if (nameCount == 2) {
// If one other player exists with the same name, initialize the nameCount
await initializeNameCount(name: name);
}
return nameCount;
}
@visibleForTesting
/// Calculates the name count for a new player with the given [name].
/// - 0 Players: Name count is 0
/// - 1 Player: Name count is 2 (since the existing player will be 1)
/// - Other: Name count is the existing count + 1
Future<int> calculateNameCount({required String name}) async {
final count = await getNameCount(name: name);
final int nameCount;
if (count == 1) {
// If one other player exists with the same name, initialize the nameCount
await initializeNameCount(name: name);
// And for the new player, set nameCount to 2
if (count == 0) {
// If no other players exist with the same name, the returned nameCount is 0
nameCount = 0;
} else if (count == 1) {
// If one other player with the name count exists, the returned name count is 2
nameCount = 2;
} else if (count > 1) {
} else {
// If more than one player exists with the same name, just increment
// the nameCount for the new player
nameCount = count + 1;
} else {
// If no other players exist with the same name, set nameCount to 0
nameCount = 0;
}
return nameCount;
}
@visibleForTesting
Future<bool> initializeNameCount({required String name}) async {
final rowsAffected =
await (update(playerTable)..where((p) => p.name.equals(name))).write(
const PlayerTableCompanion(nameCount: Value(1)),
);
await (update(playerTable)..where((tbl) => tbl.name.equals(name)))
.write(const PlayerTableCompanion(nameCount: Value(1)));
return rowsAffected > 0;
}

View File

@@ -39,18 +39,25 @@ class PlayerGroupDao extends DatabaseAccessor<AppDatabase>
/// Retrieves all players belonging to a specific group by [groupId].
Future<List<Player>> getPlayersOfGroup({required String groupId}) async {
final query = select(playerGroupTable)
..where((pG) => pG.groupId.equals(groupId));
final result = await query.get();
final query = select(playerGroupTable).join([
innerJoin(
playerTable,
playerTable.id.equalsExp(playerGroupTable.playerId),
),
])..where(playerGroupTable.groupId.equals(groupId));
List<Player> groupMembers = List.empty(growable: true);
for (var entry in result) {
final player = await db.playerDao.getPlayerById(playerId: entry.playerId);
groupMembers.add(player);
}
return groupMembers;
final result = await query.map((row) => row.readTable(playerTable)).get();
return result
.map(
(row) => Player(
id: row.id,
createdAt: row.createdAt,
name: row.name,
nameCount: row.nameCount,
description: row.description,
),
)
.toList();
}
/// Checks if a player with [playerId] is in the group with [groupId].
@@ -60,7 +67,9 @@ class PlayerGroupDao extends DatabaseAccessor<AppDatabase>
required String groupId,
}) async {
final query = select(playerGroupTable)
..where((p) => p.playerId.equals(playerId) & p.groupId.equals(groupId));
..where(
(tbl) => tbl.playerId.equals(playerId) & tbl.groupId.equals(groupId),
);
final result = await query.getSingleOrNull();
return result != null;
}
@@ -81,7 +90,7 @@ class PlayerGroupDao extends DatabaseAccessor<AppDatabase>
await db.transaction(() async {
// Remove all existing players from the group
final deleteQuery = delete(db.playerGroupTable)
..where((p) => p.groupId.equals(groupId));
..where((tbl) => tbl.groupId.equals(groupId));
await deleteQuery.go();
// Add new players to the player table if they don't exist
@@ -121,7 +130,9 @@ class PlayerGroupDao extends DatabaseAccessor<AppDatabase>
required String groupId,
}) async {
final query = delete(playerGroupTable)
..where((p) => p.playerId.equals(playerId) & p.groupId.equals(groupId));
..where(
(tbl) => tbl.playerId.equals(playerId) & tbl.groupId.equals(groupId),
);
final rowsAffected = await query.go();
return rowsAffected > 0;
}

View File

@@ -40,7 +40,7 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
await (selectOnly(playerMatchTable)
..where(playerMatchTable.matchId.equals(matchId))
..addColumns([playerMatchTable.playerId.count()]))
.map((row) => row.read(playerMatchTable.playerId.count()))
.map((tbl) => tbl.read(playerMatchTable.playerId.count()))
.getSingle();
return (count ?? 0) > 0;
}
@@ -56,7 +56,7 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
..where(playerMatchTable.matchId.equals(matchId))
..where(playerMatchTable.playerId.equals(playerId))
..addColumns([playerMatchTable.playerId.count()]))
.map((row) => row.read(playerMatchTable.playerId.count()))
.map((tbl) => tbl.read(playerMatchTable.playerId.count()))
.getSingle();
return (count ?? 0) > 0;
}
@@ -66,7 +66,7 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
Future<List<Player>> getPlayersOfMatch({required String matchId}) async {
final result = await (select(
playerMatchTable,
)..where((p) => p.matchId.equals(matchId))).get();
)..where((tbl) => tbl.matchId.equals(matchId))).get();
if (result.isEmpty) return [];
@@ -74,8 +74,7 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
(row) => db.playerDao.getPlayerById(playerId: row.playerId),
);
final players = await Future.wait(futures);
return players
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return players;
}
/// Retrieves a list of [Player]s associated with a specific team in a match.
@@ -86,8 +85,8 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
}) async {
final result =
await (select(playerMatchTable)
..where((p) => p.matchId.equals(matchId))
..where((p) => p.teamId.equals(teamId)))
..where((tbl) => tbl.matchId.equals(matchId))
..where((tbl) => tbl.teamId.equals(teamId)))
.get();
if (result.isEmpty) return [];
@@ -110,7 +109,8 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
}) async {
final rowsAffected =
await (update(playerMatchTable)..where(
(p) => p.matchId.equals(matchId) & p.playerId.equals(playerId),
(tbl) =>
tbl.matchId.equals(matchId) & tbl.playerId.equals(playerId),
))
.write(PlayerMatchTableCompanion(teamId: Value(teamId)));
return rowsAffected > 0;
@@ -144,9 +144,9 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
// Remove old players
if (playersToRemove.isNotEmpty) {
await (delete(playerMatchTable)..where(
(pg) =>
pg.matchId.equals(matchId) &
pg.playerId.isIn(playersToRemove.toList()),
(tbl) =>
tbl.matchId.equals(matchId) &
tbl.playerId.isIn(playersToRemove.toList()),
))
.go();
}
@@ -183,8 +183,8 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
required String playerId,
}) async {
final query = delete(playerMatchTable)
..where((pg) => pg.matchId.equals(matchId))
..where((pg) => pg.playerId.equals(playerId));
..where((tbl) => tbl.matchId.equals(matchId))
..where((tbl) => tbl.playerId.equals(playerId));
final rowsAffected = await query.go();
return rowsAffected > 0;
}

View File

@@ -16,12 +16,12 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
/* Create */
/// Adds a score entry to the database.
Future<bool> addScore({
Future<void> addScore({
required String playerId,
required String matchId,
required ScoreEntry entry,
}) async {
final rowsAffected = await into(scoreEntryTable).insert(
await into(scoreEntryTable).insert(
ScoreEntryTableCompanion.insert(
playerId: playerId,
matchId: matchId,
@@ -31,8 +31,6 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
),
mode: InsertMode.insertOrReplace,
);
return rowsAffected > 0;
}
Future<void> addScoresAsList({
@@ -72,10 +70,10 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
}) async {
final query = select(scoreEntryTable)
..where(
(s) =>
s.playerId.equals(playerId) &
s.matchId.equals(matchId) &
s.roundNumber.equals(roundNumber),
(tbl) =>
tbl.playerId.equals(playerId) &
tbl.matchId.equals(matchId) &
tbl.roundNumber.equals(roundNumber),
);
final result = await query.getSingleOrNull();
@@ -93,7 +91,7 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
required String matchId,
}) async {
final query = select(scoreEntryTable)
..where((s) => s.matchId.equals(matchId));
..where((tbl) => tbl.matchId.equals(matchId));
final result = await query.get();
final Map<String, ScoreEntry?> scoresByPlayer = {};
@@ -115,8 +113,10 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
required String matchId,
}) async {
final query = select(scoreEntryTable)
..where((s) => s.playerId.equals(playerId) & s.matchId.equals(matchId))
..orderBy([(s) => OrderingTerm.asc(s.roundNumber)]);
..where(
(tbl) => tbl.playerId.equals(playerId) & tbl.matchId.equals(matchId),
)
..orderBy([(tbl) => OrderingTerm.asc(tbl.roundNumber)]);
final result = await query.get();
return result
.map(
@@ -138,8 +138,8 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
final query = selectOnly(scoreEntryTable)
..where(scoreEntryTable.matchId.equals(matchId))
..addColumns([scoreEntryTable.roundNumber.max()]);
final result = await query.getSingle();
return result.read(scoreEntryTable.roundNumber.max());
final row = await query.getSingle();
return row.read(scoreEntryTable.roundNumber.max());
}
/// Aggregates the total score for a player in a match by summing all their
@@ -168,10 +168,10 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
}) async {
final rowsAffected =
await (update(scoreEntryTable)..where(
(s) =>
s.playerId.equals(playerId) &
s.matchId.equals(matchId) &
s.roundNumber.equals(entry.roundNumber),
(tbl) =>
tbl.playerId.equals(playerId) &
tbl.matchId.equals(matchId) &
tbl.roundNumber.equals(entry.roundNumber),
))
.write(
ScoreEntryTableCompanion(
@@ -192,10 +192,10 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
}) async {
final query = delete(scoreEntryTable)
..where(
(s) =>
s.playerId.equals(playerId) &
s.matchId.equals(matchId) &
s.roundNumber.equals(roundNumber),
(tbl) =>
tbl.playerId.equals(playerId) &
tbl.matchId.equals(matchId) &
tbl.roundNumber.equals(roundNumber),
);
final rowsAffected = await query.go();
return rowsAffected > 0;
@@ -203,7 +203,7 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
Future<bool> deleteAllScoresForMatch({required String matchId}) async {
final query = delete(scoreEntryTable)
..where((s) => s.matchId.equals(matchId));
..where((tbl) => tbl.matchId.equals(matchId));
final rowsAffected = await query.go();
return rowsAffected > 0;
}
@@ -213,7 +213,9 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
required String playerId,
}) async {
final query = delete(scoreEntryTable)
..where((s) => s.playerId.equals(playerId) & s.matchId.equals(matchId));
..where(
(tbl) => tbl.playerId.equals(playerId) & tbl.matchId.equals(matchId),
);
final rowsAffected = await query.go();
return rowsAffected > 0;
}

View File

@@ -0,0 +1,127 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/db/tables/statistic_table.dart';
import 'package:tallee/data/models/statistic.dart';
part 'statistic_dao.g.dart';
@DriftAccessor(tables: [StatisticTable])
class StatisticDao extends DatabaseAccessor<AppDatabase>
with _$StatisticDaoMixin {
StatisticDao(super.db);
/* Create */
Future<bool> addStatistic({required Statistic statistic}) async {
await into(statisticTable).insert(
StatisticTableCompanion.insert(
id: statistic.id,
type: statistic.type.name,
timeframe: Value(statistic.timeframe?.name),
displayCount: Value(statistic.displayCount),
),
mode: InsertMode.insertOrReplace,
);
await db.statisticScopeDao.addStatisticScopes(
statisticId: statistic.id,
scopes: statistic.scopes,
);
if (statistic.selectedGroups != null) {
await db.statisticGroupDao.addStatisticGroups(
statisticId: statistic.id,
groups: statistic.selectedGroups!,
);
}
if (statistic.selectedGames != null) {
await db.statisticGameDao.addStatisticGames(
statisticId: statistic.id,
games: statistic.selectedGames!,
);
}
return true;
}
/* Read */
Future<Statistic?> getStatisticById(String statisticId) async {
final query = select(statisticTable);
final row = await query.getSingleOrNull();
if (row != null) {
final groups = await db.statisticGroupDao.getGroupsForStatistic(row.id);
final games = await db.statisticGameDao.getGamesForStatistic(row.id);
final scopes = await db.statisticScopeDao.getScopeForStatistic(row.id);
return Statistic(
type: StatisticType.values.firstWhere((type) => type.name == row.type),
scopes: scopes,
timeframe: Timeframe.values.firstWhereOrNull(
(t) => t.name == row.timeframe,
),
selectedGroups: groups,
selectedGames: games,
displayCount: row.displayCount,
id: row.id,
);
}
return null;
}
/// Retrieves all statistics from the database, including their associated groups and games.
Future<List<Statistic>> getAllStatistics() async {
final query = select(statisticTable);
final result = await query.get();
return Future.wait(
result.map((row) async {
final groups = await db.statisticGroupDao.getGroupsForStatistic(row.id);
final games = await db.statisticGameDao.getGamesForStatistic(row.id);
final scopes = await db.statisticScopeDao.getScopeForStatistic(row.id);
return Statistic(
type: StatisticType.values.firstWhere(
(type) => type.name == row.type,
),
scopes: scopes,
timeframe: Timeframe.values.firstWhereOrNull(
(t) => t.name == row.timeframe,
),
selectedGroups: groups,
selectedGames: games,
displayCount: row.displayCount,
id: row.id,
);
}),
);
}
/* Update */
Future<bool> updateDisplayCount(String statisticId, int displayCount) async {
final rowsUpdated =
await (update(statisticTable)
..where((tbl) => tbl.id.equals(statisticId)))
.write(StatisticTableCompanion(displayCount: Value(displayCount)));
return rowsUpdated > 0;
}
/* Delete */
Future<bool> deleteStatistic(String statisticId) async {
final rowsDeleted = await (delete(
statisticTable,
)..where((tbl) => tbl.id.equals(statisticId))).go();
return rowsDeleted > 0;
}
Future<bool> deleteAllStatistics() async {
final rowsDeleted = await delete(statisticTable).go();
return rowsDeleted > 0;
}
}

View File

@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'statistic_dao.dart';
// ignore_for_file: type=lint
mixin _$StatisticDaoMixin on DatabaseAccessor<AppDatabase> {
$StatisticTableTable get statisticTable => attachedDatabase.statisticTable;
StatisticDaoManager get managers => StatisticDaoManager(this);
}
class StatisticDaoManager {
final _$StatisticDaoMixin _db;
StatisticDaoManager(this._db);
$$StatisticTableTableTableManager get statisticTable =>
$$StatisticTableTableTableManager(
_db.attachedDatabase,
_db.statisticTable,
);
}

View File

@@ -0,0 +1,61 @@
import 'package:drift/drift.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/db/tables/statistic_game_table.dart';
import 'package:tallee/data/models/game.dart';
part 'statistic_game_dao.g.dart';
@DriftAccessor(tables: [StatisticGameTable])
class StatisticGameDao extends DatabaseAccessor<AppDatabase>
with _$StatisticGameDaoMixin {
StatisticGameDao(super.db);
/// Retrieves a list of games associated with a specific statistic.
Future<List<Game>?> getGamesForStatistic(String statisticId) async {
final query = select(statisticGameTable).join([
innerJoin(gameTable, gameTable.id.equalsExp(statisticGameTable.gameId)),
])..where(statisticGameTable.statisticId.equals(statisticId));
final results = await query.map((row) => row.readTable(gameTable)).get();
if (results.isEmpty) return null;
return results
.map(
(row) => Game(
id: row.id,
name: row.name,
ruleset: Ruleset.values.firstWhere((e) => e.name == row.ruleset),
description: row.description,
color: AppColor.values.firstWhere((e) => e.name == row.color),
icon: row.icon,
createdAt: row.createdAt,
),
)
.toList();
}
Future<bool> addStatisticGames({
required String statisticId,
required List<Game> games,
}) {
final entries = games
.map(
(game) => StatisticGameTableCompanion.insert(
statisticId: statisticId,
gameId: game.id,
),
)
.toList();
return batch((batch) {
batch.insertAll(
statisticGameTable,
entries,
mode: InsertMode.insertOrReplace,
);
}).then((_) => true).catchError((error) {
print('Error adding statistic games: $error');
return false;
});
}
}

View File

@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'statistic_game_dao.dart';
// ignore_for_file: type=lint
mixin _$StatisticGameDaoMixin on DatabaseAccessor<AppDatabase> {
$StatisticTableTable get statisticTable => attachedDatabase.statisticTable;
$GameTableTable get gameTable => attachedDatabase.gameTable;
$StatisticGameTableTable get statisticGameTable =>
attachedDatabase.statisticGameTable;
StatisticGameDaoManager get managers => StatisticGameDaoManager(this);
}
class StatisticGameDaoManager {
final _$StatisticGameDaoMixin _db;
StatisticGameDaoManager(this._db);
$$StatisticTableTableTableManager get statisticTable =>
$$StatisticTableTableTableManager(
_db.attachedDatabase,
_db.statisticTable,
);
$$GameTableTableTableManager get gameTable =>
$$GameTableTableTableManager(_db.attachedDatabase, _db.gameTable);
$$StatisticGameTableTableTableManager get statisticGameTable =>
$$StatisticGameTableTableTableManager(
_db.attachedDatabase,
_db.statisticGameTable,
);
}

View File

@@ -0,0 +1,67 @@
import 'package:drift/drift.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/db/tables/group_table.dart';
import 'package:tallee/data/db/tables/statistic_group_table.dart';
import 'package:tallee/data/models/group.dart';
part 'statistic_group_dao.g.dart';
@DriftAccessor(tables: [StatisticGroupTable, GroupTable])
class StatisticGroupDao extends DatabaseAccessor<AppDatabase>
with _$StatisticGroupDaoMixin {
StatisticGroupDao(super.db);
/// Retrieves a list of groups associated with a specific statistic.
Future<List<Group>?> getGroupsForStatistic(String statisticId) async {
final query = select(statisticGroupTable).join([
innerJoin(
groupTable,
groupTable.id.equalsExp(statisticGroupTable.groupId),
),
])..where(statisticGroupTable.statisticId.equals(statisticId));
final results = await query.map((row) => row.readTable(groupTable)).get();
if (results.isEmpty) return null;
final groups = await Future.wait(
results.map((result) async {
final groupMembers = await db.playerGroupDao.getPlayersOfGroup(
groupId: result.id,
);
return Group(
id: result.id,
createdAt: result.createdAt,
name: result.name,
description: result.description,
members: groupMembers,
);
}),
);
return groups;
}
Future<bool> addStatisticGroups({
required String statisticId,
required List<Group> groups,
}) async {
final entries = groups
.map(
(group) => StatisticGroupTableCompanion.insert(
statisticId: statisticId,
groupId: group.id,
),
)
.toList();
return batch((batch) {
batch.insertAll(
statisticGroupTable,
entries,
mode: InsertMode.insertOrReplace,
);
}).then((_) => true).catchError((error) {
print('Error adding statistic groups: $error');
return false;
});
}
}

View File

@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'statistic_group_dao.dart';
// ignore_for_file: type=lint
mixin _$StatisticGroupDaoMixin on DatabaseAccessor<AppDatabase> {
$StatisticTableTable get statisticTable => attachedDatabase.statisticTable;
$GroupTableTable get groupTable => attachedDatabase.groupTable;
$StatisticGroupTableTable get statisticGroupTable =>
attachedDatabase.statisticGroupTable;
StatisticGroupDaoManager get managers => StatisticGroupDaoManager(this);
}
class StatisticGroupDaoManager {
final _$StatisticGroupDaoMixin _db;
StatisticGroupDaoManager(this._db);
$$StatisticTableTableTableManager get statisticTable =>
$$StatisticTableTableTableManager(
_db.attachedDatabase,
_db.statisticTable,
);
$$GroupTableTableTableManager get groupTable =>
$$GroupTableTableTableManager(_db.attachedDatabase, _db.groupTable);
$$StatisticGroupTableTableTableManager get statisticGroupTable =>
$$StatisticGroupTableTableTableManager(
_db.attachedDatabase,
_db.statisticGroupTable,
);
}

View File

@@ -0,0 +1,55 @@
import 'package:drift/drift.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/db/tables/statistic_scope_table.dart';
part 'statistic_scope_dao.g.dart';
@DriftAccessor(tables: [StatisticScopeTable])
class StatisticScopeDao extends DatabaseAccessor<AppDatabase>
with _$StatisticScopeDaoMixin {
StatisticScopeDao(super.db);
/// Retrieves a list of statistic scopes associated with a specific statistic ID.
Future<List<StatisticScope>> getScopeForStatistic(String statisticId) async {
final query = select(statisticScopeTable)
..where((tbl) => tbl.statisticId.equals(statisticId));
final result = await query.get();
return result
.map(
(row) => StatisticScope.values.firstWhere(
(e) => e.name == row.scope,
orElse: () => throw Exception(
'Invalid scope value: ${row.scope} for statistic ID: $statisticId',
),
),
)
.toList();
}
Future<bool> addStatisticScopes({
required String statisticId,
required List<StatisticScope> scopes,
}) async {
final entries = scopes
.map(
(scope) => StatisticScopeTableCompanion.insert(
statisticId: statisticId,
scope: scope.name,
),
)
.toList();
return batch((batch) {
batch.insertAll(
statisticScopeTable,
entries,
mode: InsertMode.insertOrReplace,
);
}).then((_) => true).catchError((error) {
print('Error adding statistic scopes: $error');
return false;
});
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'statistic_scope_dao.dart';
// ignore_for_file: type=lint
mixin _$StatisticScopeDaoMixin on DatabaseAccessor<AppDatabase> {
$StatisticTableTable get statisticTable => attachedDatabase.statisticTable;
$StatisticScopeTableTable get statisticScopeTable =>
attachedDatabase.statisticScopeTable;
StatisticScopeDaoManager get managers => StatisticScopeDaoManager(this);
}
class StatisticScopeDaoManager {
final _$StatisticScopeDaoMixin _db;
StatisticScopeDaoManager(this._db);
$$StatisticTableTableTableManager get statisticTable =>
$$StatisticTableTableTableManager(
_db.attachedDatabase,
_db.statisticTable,
);
$$StatisticScopeTableTableTableManager get statisticScopeTable =>
$$StatisticScopeTableTableTableManager(
_db.attachedDatabase,
_db.statisticScopeTable,
);
}

View File

@@ -1,10 +1,8 @@
import 'package:drift/drift.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/db/tables/player_match_table.dart';
import 'package:tallee/data/db/tables/team_table.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/score_entry.dart';
import 'package:tallee/data/models/team.dart';
part 'team_dao.g.dart';
@@ -24,8 +22,6 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
id: team.id,
name: team.name,
createdAt: team.createdAt,
color: Value(team.color.name),
score: Value(team.score),
),
mode: InsertMode.insertOrReplace,
);
@@ -60,8 +56,6 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
id: team.id,
name: team.name,
createdAt: team.createdAt,
color: Value(team.color.name),
score: Value(team.score),
),
)
.toList(),
@@ -92,7 +86,7 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
Future<int> getTeamCount() async {
final count =
await (selectOnly(teamTable)..addColumns([teamTable.id.count()]))
.map((row) => row.read(teamTable.id.count()))
.map((tbl) => tbl.read(teamTable.id.count()))
.getSingle();
return count ?? 0;
}
@@ -101,8 +95,8 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
/// Returns `true` if the team exists, `false` otherwise.
Future<bool> teamExists({required String teamId}) async {
final query = select(teamTable)..where((t) => t.id.equals(teamId));
final result = await query.getSingleOrNull();
return result != null;
final row = await query.getSingleOrNull();
return row != null;
}
/// Retrieves all teams from the database.
@@ -116,43 +110,21 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
id: row.id,
name: row.name,
createdAt: row.createdAt,
color: AppColor.values.byName(row.color),
score: row.score,
members: members,
);
}),
);
}
Future<List<Team>> getTeamsByMatchId({required String matchId}) async {
final playerMatchQuery = select(db.playerMatchTable)
..where((pm) => pm.matchId.equals(matchId));
final playerMatches = await playerMatchQuery.get();
if (playerMatches.isEmpty) return [];
final teamIds = playerMatches
.map((pm) => pm.teamId)
.whereType<String>()
.toSet();
final teams = await Future.wait(
teamIds.map((id) => getTeamById(teamId: id)),
);
return teams;
}
/// Retrieves a [Team] by its [teamId], including its members.
Future<Team> getTeamById({required String teamId}) async {
final query = select(teamTable)..where((t) => t.id.equals(teamId));
final result = await query.getSingle();
final row = await query.getSingle();
final members = await _getTeamMembers(teamId: teamId);
return Team(
id: result.id,
name: result.name,
createdAt: result.createdAt,
color: AppColor.values.byName(result.color),
score: result.score,
id: row.id,
name: row.name,
createdAt: row.createdAt,
members: members,
);
}
@@ -161,20 +133,19 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
Future<List<Player>> _getTeamMembers({required String teamId}) async {
// Get all player_match entries with this teamId
final playerMatchQuery = select(db.playerMatchTable)
..where((pm) => pm.teamId.equals(teamId));
..where((tbl) => tbl.teamId.equals(teamId));
final playerMatches = await playerMatchQuery.get();
if (playerMatches.isEmpty) return [];
// Get unique player IDs
final playerIds = playerMatches.map((pm) => pm.playerId).toSet();
final playerIds = playerMatches.map((tbl) => tbl.playerId).toSet();
// Fetch all players
final players = await Future.wait(
playerIds.map((id) => db.playerDao.getPlayerById(playerId: id)),
);
return players
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return players;
}
/* Update */
@@ -185,87 +156,12 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
required String name,
}) async {
final rowsAffected =
await (update(teamTable)..where((t) => t.id.equals(teamId))).write(
await (update(teamTable)..where((tbl) => tbl.id.equals(teamId))).write(
TeamTableCompanion(name: Value(name)),
);
return rowsAffected > 0;
}
/// Updates the color of the team with the given [teamId].
Future<bool> updateTeamColor({
required String teamId,
required AppColor color,
}) async {
final rowsAffected =
await (update(teamTable)..where((t) => t.id.equals(teamId))).write(
TeamTableCompanion(color: Value(color.name)),
);
return rowsAffected > 0;
}
/// Updates the score of the team with the given [teamId].
/// Updates the member scores correspondingly
Future<bool> updateTeamScore({
required String teamId,
required String matchId,
required int score,
}) async {
await (update(teamTable)..where((t) => t.id.equals(teamId))).write(
const TeamTableCompanion(score: Value(null)),
);
await _deleteAllScoresForMembersOfTeam(teamId: teamId, matchId: matchId);
final rowsAffected =
await (update(teamTable)..where((t) => t.id.equals(teamId))).write(
TeamTableCompanion(score: Value(score)),
);
final members = await _getTeamMembers(teamId: teamId);
for (final member in members) {
await db.scoreEntryDao.addScore(
playerId: member.id,
matchId: matchId,
entry: ScoreEntry(score: score),
);
}
return rowsAffected > 0;
}
Future<bool> removeScoreForTeam({
required String teamId,
required String matchId,
}) async {
await (update(teamTable)..where((t) => t.id.equals(teamId))).write(
const TeamTableCompanion(score: Value(null)),
);
await _deleteAllScoresForMembersOfTeam(teamId: teamId, matchId: matchId);
return true;
}
/// Removes the scores for all teams in the match with the given [matchId] by setting their scores to null.
Future<bool> removeAllTeamScores({required String matchId}) async {
// collect all teamIds for the given matchId from playerMatchTable
final teamIds =
await (selectOnly(playerMatchTable)
..addColumns([playerMatchTable.teamId])
..where(playerMatchTable.matchId.equals(matchId)))
.map((row) => row.read(playerMatchTable.teamId))
.get();
// filter null or duplicates
final filteredTeamIds = teamIds.whereType<String>().toSet().toList();
var rowsAffected = 0;
if (filteredTeamIds.isNotEmpty) {
rowsAffected =
await (update(teamTable)..where((t) => t.id.isIn(filteredTeamIds)))
.write(const TeamTableCompanion(score: Value(null)));
}
await db.scoreEntryDao.deleteAllScoresForMatch(matchId: matchId);
return rowsAffected > 0;
}
/* Delete */
/// Deletes all teams from the database.
@@ -279,96 +175,8 @@ class TeamDao extends DatabaseAccessor<AppDatabase> with _$TeamDaoMixin {
/// Deletes the team with the given [teamId] from the database.
/// Returns `true` if the team was deleted, `false` otherwise.
Future<bool> deleteTeam({required String teamId}) async {
final query = delete(teamTable)..where((t) => t.id.equals(teamId));
final query = delete(teamTable)..where((tbl) => tbl.id.equals(teamId));
final rowsAffected = await query.go();
return rowsAffected > 0;
}
/* Score handling */
/// Sets the team with the given [teamId] as the winner of the match with the given [matchId] by assigning a score of 1.
/// Returns `true` if the score was updated successfully, `false` otherwise.
Future<bool> setWinnerTeam({
required String teamId,
required String matchId,
}) async {
return await updateTeamScore(teamId: teamId, matchId: matchId, score: 1);
}
/// Sets multiple teams as winners of the match with the given [matchId] by assigning a score of 1 to each team.
/// Returns `true` if all scores were updated successfully, `false` otherwise.
Future<bool> setWinnerTeams({
required List<Team> winners,
required String matchId,
}) async {
// Reset all team scores .
await removeAllTeamScores(matchId: matchId);
// Reset all score entries
for (final team in winners) {
await _deleteAllScoresForMembersOfTeam(teamId: team.id, matchId: matchId);
}
for (final team in winners) {
await updateTeamScore(teamId: team.id, matchId: matchId, score: 1);
}
return true;
}
/// Removes the winner status from all Teams with the given [matchId] by setting its score to null.
/// Returns `true` if the score was updated successfully, `false` otherwise.
Future<bool> removeWinnerTeam({required String matchId}) async {
return await removeAllTeamScores(matchId: matchId);
}
/// Sets the team with the given [teamId] as the loser of the match with the given [matchId] by assigning a score of 0.
/// Returns `true` if the score was updated successfully, `false` otherwise.
Future<bool> setLoserTeam({
required String teamId,
required String matchId,
}) async {
return await updateTeamScore(teamId: teamId, matchId: matchId, score: 0);
}
/// Removes the loser from the match with the given [matchId] by setting its score to null.
/// Returns `true` if the score was updated successfully, `false` otherwise.
Future<bool> removeLoserTeam({required String matchId}) async {
return await removeAllTeamScores(matchId: matchId);
}
/// Sets the placements for the teams in the match with the given [matchId] by assigning scores based on their order in the [teams] list.
/// Returns `true` if all scores were updated successfully, `false` otherwise.
Future<bool> setTeamPlacements({
required String matchId,
required List<Team> teams,
}) async {
List<bool?> success = List.generate(teams.length, (index) => null);
for (int i = 0; i < teams.length; i++) {
success[i] = await updateTeamScore(
matchId: matchId,
teamId: teams[i].id,
score: teams.length - i,
);
}
return success.every((result) => result == true);
}
/// Helper method to delete all scores for members of a team in a specific match.
Future<bool> _deleteAllScoresForMembersOfTeam({
required String teamId,
required String matchId,
}) async {
final playerMatchQuery = select(db.playerMatchTable)
..where((pm) => pm.teamId.equals(teamId) & pm.matchId.equals(matchId));
final playerMatches = await playerMatchQuery.get();
if (playerMatches.isEmpty) return false;
for (final pm in playerMatches) {
await db.scoreEntryDao.deleteAllScoresForPlayerInMatch(
playerId: pm.playerId,
matchId: matchId,
);
}
return true;
}
}

View File

@@ -8,6 +8,10 @@ import 'package:tallee/data/dao/player_dao.dart';
import 'package:tallee/data/dao/player_group_dao.dart';
import 'package:tallee/data/dao/player_match_dao.dart';
import 'package:tallee/data/dao/score_entry_dao.dart';
import 'package:tallee/data/dao/statistic_dao.dart';
import 'package:tallee/data/dao/statistic_game_dao.dart';
import 'package:tallee/data/dao/statistic_group_dao.dart';
import 'package:tallee/data/dao/statistic_scope_dao.dart';
import 'package:tallee/data/dao/team_dao.dart';
import 'package:tallee/data/db/tables/game_table.dart';
import 'package:tallee/data/db/tables/group_table.dart';
@@ -16,6 +20,10 @@ import 'package:tallee/data/db/tables/player_group_table.dart';
import 'package:tallee/data/db/tables/player_match_table.dart';
import 'package:tallee/data/db/tables/player_table.dart';
import 'package:tallee/data/db/tables/score_entry_table.dart';
import 'package:tallee/data/db/tables/statistic_game_table.dart';
import 'package:tallee/data/db/tables/statistic_group_table.dart';
import 'package:tallee/data/db/tables/statistic_scope_table.dart';
import 'package:tallee/data/db/tables/statistic_table.dart';
import 'package:tallee/data/db/tables/team_table.dart';
part 'database.g.dart';
@@ -30,6 +38,10 @@ part 'database.g.dart';
GameTable,
TeamTable,
ScoreEntryTable,
StatisticTable,
StatisticScopeTable,
StatisticGameTable,
StatisticGroupTable,
],
daos: [
PlayerDao,
@@ -40,6 +52,10 @@ part 'database.g.dart';
GameDao,
ScoreEntryDao,
TeamDao,
StatisticDao,
StatisticScopeDao,
StatisticGameDao,
StatisticGroupDao,
],
)
class AppDatabase extends _$AppDatabase {

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ class MatchTable extends Table {
.references(GroupTable, #id, onDelete: KeyAction.setNull)
.nullable()();
TextColumn get name => text()();
BoolColumn get isTeamMatch => boolean().withDefault(const Constant(false))();
TextColumn get notes => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get endedAt => dateTime().nullable()();

View File

@@ -0,0 +1,13 @@
import 'package:drift/drift.dart';
import 'package:tallee/data/db/tables/game_table.dart';
import 'package:tallee/data/db/tables/statistic_table.dart';
class StatisticGameTable extends Table {
TextColumn get statisticId =>
text().references(StatisticTable, #id, onDelete: KeyAction.cascade)();
TextColumn get gameId =>
text().references(GameTable, #id, onDelete: KeyAction.cascade)();
@override
Set<Column<Object>> get primaryKey => {statisticId, gameId};
}

View File

@@ -0,0 +1,13 @@
import 'package:drift/drift.dart';
import 'package:tallee/data/db/tables/group_table.dart';
import 'package:tallee/data/db/tables/statistic_table.dart';
class StatisticGroupTable extends Table {
TextColumn get statisticId =>
text().references(StatisticTable, #id, onDelete: KeyAction.cascade)();
TextColumn get groupId =>
text().references(GroupTable, #id, onDelete: KeyAction.cascade)();
@override
Set<Column<Object>> get primaryKey => {statisticId, groupId};
}

View File

@@ -0,0 +1,11 @@
import 'package:drift/drift.dart';
import 'package:tallee/data/db/tables/statistic_table.dart';
class StatisticScopeTable extends Table {
TextColumn get statisticId =>
text().references(StatisticTable, #id, onDelete: KeyAction.cascade)();
TextColumn get scope => text()();
@override
Set<Column<Object>> get primaryKey => {statisticId, scope};
}

View File

@@ -0,0 +1,11 @@
import 'package:drift/drift.dart';
class StatisticTable extends Table {
TextColumn get id => text()();
TextColumn get type => text()();
TextColumn get timeframe => text().nullable()();
IntColumn get displayCount => integer().withDefault(const Constant(5))();
@override
Set<Column<Object>> get primaryKey => {id};
}

View File

@@ -4,8 +4,6 @@ class TeamTable extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
DateTimeColumn get createdAt => dateTime()();
TextColumn get color => text().withDefault(const Constant('blue'))();
IntColumn get score => integer().nullable()();
@override
Set<Column<Object>> get primaryKey => {id};

View File

@@ -73,10 +73,7 @@ class Game {
orElse: () => Ruleset.singleWinner,
),
description = json['description'],
color = AppColor.values.firstWhere(
(e) => e.name == json['color'],
orElse: () => AppColor.orange,
),
color = AppColor.values.firstWhere((e) => e.name == json['color']),
icon = json['icon'];
Map<String, dynamic> toJson() => {

View File

@@ -5,17 +5,17 @@ import 'package:uuid/uuid.dart';
class Group {
final String id;
final String name;
final String description;
final DateTime createdAt;
final String name;
final List<Player> members;
final String description;
Group({
required this.name,
required this.members,
String? id,
DateTime? createdAt,
required this.name,
String? description,
required this.members,
}) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? clock.now(),
description = description ?? '';

View File

@@ -16,10 +16,9 @@ class Match {
final Game game;
final Group? group;
final List<Player> players;
final bool isTeamMatch;
final List<Team>? teams;
final String notes;
final Map<String, ScoreEntry?> scores;
Map<String, ScoreEntry?> scores;
Match({
required this.name,
@@ -27,7 +26,6 @@ class Match {
required this.players,
this.endedAt,
this.group,
this.isTeamMatch = false,
this.teams,
this.notes = '',
String? id,
@@ -39,7 +37,7 @@ class Match {
@override
String toString() {
return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, isTeamMatch: $isTeamMatch, teams: $teams, notes: $notes, scores: $scores, mvp: $mvp}';
return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, notes: $notes, scores: $scores, mvp: $mvp}';
}
Match copyWith({
@@ -50,7 +48,6 @@ class Match {
Game? game,
Group? group,
List<Player>? players,
bool? isTeamMatch,
List<Team>? teams,
String? notes,
Map<String, ScoreEntry?>? scores,
@@ -63,7 +60,6 @@ class Match {
game: game ?? this.game,
group: group ?? this.group,
players: players ?? this.players,
isTeamMatch: isTeamMatch ?? this.isTeamMatch,
teams: teams ?? this.teams,
notes: notes ?? this.notes,
scores: scores ?? this.scores,
@@ -82,7 +78,6 @@ class Match {
game == other.game &&
group == other.group &&
const DeepCollectionEquality().equals(players, other.players) &&
isTeamMatch == other.isTeamMatch &&
const DeepCollectionEquality().equals(teams, other.teams) &&
notes == other.notes &&
const DeepCollectionEquality().equals(scores, other.scores);
@@ -96,7 +91,6 @@ class Match {
game,
group,
const DeepCollectionEquality().hash(players),
isTeamMatch,
const DeepCollectionEquality().hash(teams),
notes,
const DeepCollectionEquality().hash(scores),
@@ -118,7 +112,6 @@ class Match {
),
group = null,
players = [],
isTeamMatch = json['isTeamMatch'],
teams = [],
scores = json['scores'] != null
? (json['scores'] as Map<String, dynamic>).map(
@@ -140,13 +133,11 @@ class Match {
'gameId': game.id,
'groupId': group?.id,
'playerIds': players.map((player) => player.id).toList(),
'isTeamMatch': isTeamMatch,
'teams': teams?.map((team) => team.toJson()).toList(),
'scores': scores.map((key, value) => MapEntry(key, value?.toJson())),
'notes': notes,
};
// Most Valuable Player(s) based on the match's ruleset
List<Player> get mvp {
if (players.isEmpty || scores.isEmpty) return [];
@@ -204,59 +195,4 @@ class Match {
return playerScore.score == lowestScore;
}).toList();
}
// MVP for team-based matches (Most Valuable Team)
List<Team> get mvt {
if (teams == null || teams!.isEmpty) return [];
switch (game.ruleset) {
case Ruleset.highestScore:
return _getHighestScoreTeam();
case Ruleset.lowestScore:
return _getLowestScoreTeam();
case Ruleset.singleWinner:
return _getHighestScoreTeam().take(1).toList();
case Ruleset.singleLoser:
return _getLowestScoreTeam().take(1).toList();
case Ruleset.multipleWinners:
return _getHighestScoreTeam();
case Ruleset.placement:
return _getHighestScoreTeam().take(1).toList();
}
}
List<Team> _getHighestScoreTeam() {
if (teams!.every((team) => team.score == null)) {
return [];
}
final int highestScore = teams!
.map((team) => team.score)
.whereType<int>()
.reduce((max, score) => score > max ? score : max);
return teams!.where((team) {
return team.score == highestScore;
}).toList();
}
List<Team> _getLowestScoreTeam() {
if (teams!.every((team) => team.score == null)) {
return [];
}
final int lowestScore = teams!
.map((team) => team.score)
.whereType<int>()
.reduce((min, score) => score < min ? score : min);
return teams!.where((team) {
return team.score == lowestScore;
}).toList();
}
}

View File

@@ -0,0 +1,48 @@
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:uuid/uuid.dart';
class Statistic {
final String id;
final StatisticType type;
final List<StatisticScope> scopes;
final Timeframe? timeframe;
final List<Group>? selectedGroups;
final List<Game>? selectedGames;
final int displayCount;
Statistic({
required this.type,
required this.scopes,
this.timeframe,
this.selectedGroups,
this.selectedGames,
this.displayCount = 5,
String? id,
}) : id = id ?? const Uuid().v4();
@override
String toString() {
return 'Statistic(id: $id, type: $type, scopes: $scopes, timeframe: $timeframe, selectedGroups: $selectedGroups, selectedGames: $selectedGames)';
}
Statistic copyWith({
StatisticType? type,
List<StatisticScope>? scopes,
Timeframe? timeframe,
List<Group>? selectedGroups,
List<Game>? selectedGames,
int? displayCount,
}) {
return Statistic(
id: id,
type: type ?? this.type,
scopes: scopes ?? this.scopes,
timeframe: timeframe ?? this.timeframe,
selectedGroups: selectedGroups ?? this.selectedGroups,
selectedGames: selectedGames ?? this.selectedGames,
displayCount: displayCount ?? this.displayCount,
);
}
}

View File

@@ -1,6 +1,5 @@
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/player.dart';
import 'package:uuid/uuid.dart';
@@ -8,39 +7,31 @@ class Team {
final String id;
final String name;
final DateTime createdAt;
final AppColor color;
final int? score;
final List<Player> members;
Team({
String? id,
required this.name,
DateTime? createdAt,
this.color = AppColor.blue,
this.score,
required this.members,
}) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? clock.now();
@override
String toString() {
return 'Team{id: $id, name: $name, color: $color, score: $score, members: $members}';
return 'Team{id: $id, name: $name, members: $members}';
}
Team copyWith({
String? id,
String? name,
DateTime? createdAt,
AppColor? color,
int? score,
List<Player>? members,
}) {
return Team(
id: id ?? this.id,
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
color: color ?? this.color,
score: score ?? this.score,
members: members ?? this.members,
);
}
@@ -53,8 +44,6 @@ class Team {
id == other.id &&
name == other.name &&
createdAt == other.createdAt &&
color == other.color &&
score == other.score &&
const DeepCollectionEquality().equals(members, other.members);
@override
@@ -62,8 +51,6 @@ class Team {
id,
name,
createdAt,
color,
score,
const DeepCollectionEquality().hash(members),
);
@@ -71,19 +58,12 @@ class Team {
: id = json['id'],
name = json['name'],
createdAt = DateTime.parse(json['createdAt']),
color = AppColor.values.firstWhere(
(e) => e.name == json['color'],
orElse: () => AppColor.orange,
),
score = json['score'] ?? 0,
members = []; // Populated during import via DataTransferService
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'createdAt': createdAt.toIso8601String(),
'color': color.name,
'score': score,
'memberIds': members.map((member) => member.id).toList(),
};
}

View File

@@ -1,16 +1,19 @@
{
"@@locale": "de",
"add_team": "Team hinzufügen",
"all_players": "Alle Spieler:innen",
"all_players_selected": "Alle Spieler:innen ausgewählt",
"all_time": "Gesamter Zeitraum",
"amount_of_matches": "Anzahl der Spiele",
"app_name": "Tallee",
"average_score": "Durchschnittliche Punktzahl",
"best_player": "Beste:r Spieler:in",
"best_score": "Beste Punktzahl",
"cancel": "Abbrechen",
"choose_color": "Farbe wählen",
"choose_game": "Spielvorlage wählen",
"choose_group": "Gruppe wählen",
"choose_ruleset": "Regelwerk wählen",
"classifier": "Klassifikator",
"color": "Farbe",
"color_blue": "Blau",
"color_green": "Grün",
@@ -20,13 +23,31 @@
"color_red": "Rot",
"color_teal": "Türkis",
"color_yellow": "Gelb",
"confirm": "Bestätigen",
"could_not_add_player": "Spieler:in {playerName} konnte nicht hinzugefügt werden",
"@could_not_add_player": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"create_game": "Spielvorlage erstellen",
"create_group": "Gruppe erstellen",
"create_match": "Spiel erstellen",
"create_new_group": "Neue Gruppe erstellen",
"create_new_match": "Neues Spiel erstellen",
"create_teams": "Teams erstellen",
"create_statistic": "Statistik erstellen",
"create_statistic_classifier_subtitle": "Wähle die anzuzeigende Hauptmetrik aus",
"create_statistic_classifier_title": "Klassifikator",
"create_statistic_games_subtitle": "Wähle die gefilterten Spielvorlagen",
"create_statistic_games_title": "Spielvorlagen",
"create_statistic_groups_subtitle": "Wähle die gefilterten Gruppen",
"create_statistic_groups_title": "Gruppen",
"create_statistic_scope_subtitle": "Wähle den Hauptfilter für deine Statistik. Er bestimmt, welche Daten zur Berechnung des Klassifikators verwendet werden.",
"create_statistic_scope_title": "Bereich",
"create_statistic_timeframe_subtitle": "Wähle einen Zeitraum, nach dem die Daten gefiltert werden. Nur Spiele, die innerhalb des Zeitraums beendet wurden, fließen in die Statistik ein.",
"create_statistic_timeframe_title": "Zeitraum",
"created_on": "Erstellt am",
"data": "Daten",
"data_successfully_deleted": "Daten erfolgreich gelöscht",
@@ -46,11 +67,15 @@
},
"delete_group": "Gruppe löschen",
"delete_match": "Spiel löschen",
"delete_player": "Spieler:in löschen",
"description": "Beschreibung",
"displayed_entries": "Angezeigte Einträge",
"drag_to_set_placement": "Ziehen um Platzierung zu setzen",
"edit_game": "Spielvorlage bearbeiten",
"edit_group": "Gruppe bearbeiten",
"edit_match": "Gruppe bearbeiten",
"edit_name": "Name ändern",
"edit_player": "Spieler bearbeiten",
"enter_points": "Punkte eingeben",
"enter_results": "Ergebnisse eintragen",
"error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen",
@@ -61,32 +86,42 @@
"exit_view": "Ansicht verlassen",
"export_canceled": "Export abgebrochen",
"export_data": "Daten exportieren",
"filter": "Filter",
"format_exception": "Formatfehler (siehe Konsole)",
"game": "Spielvorlage",
"game_name": "Spielvorlagenname",
"games": "Spielvorlagen",
"group": "Gruppe",
"group_name": "Gruppenname",
"group_profile": "Gruppenprofil",
"groups": "Gruppen",
"groups_part_of": "Gruppen Teil von",
"highest_score": "Höchste Punkte",
"home": "Startseite",
"import_canceled": "Import abgebrochen",
"import_data": "Daten importieren",
"info": "Info",
"invalid_schema": "Ungültiges Schema",
"last_180_days": "Letzte 180 Tage",
"last_30_days": "Letzte 30 Tage",
"last_7_days": "Letzte 7 Tage",
"last_90_days": "Letzte 90 Tage",
"last_year": "Letztes Jahr",
"least_points": "Niedrigste Punkte",
"legal": "Rechtliches",
"legal_notice": "Impressum",
"licenses": "Lizenzen",
"live_edit_mode": "Live-Bearbeitungsmodus",
"loading": "Lädt...",
"loser": "Verlierer:in",
"lowest_score": "Niedrigste Punkte",
"manage_members": "Mitglieder bearbeiten",
"match_in_progress": "Spiel läuft...",
"match_name": "Spieltitel",
"match_profile": "Spielprofil",
"matches": "Spiele",
"member": "Mitglied",
"matches_part_of": "Spiele Teil von",
"matches_played": "Spiele gespielt",
"matches_won": "Spiele gewonnen",
"members": "Mitglieder",
"most_points": "Höchste Punkte",
"multiple_winners": "Mehrere Gewinner:innen",
@@ -96,7 +131,7 @@
"no_license_text_available": "Kein Lizenztext verfügbar",
"no_licenses_found": "Keine Lizenzen gefunden",
"no_matches_created_yet": "Noch keine Spiele erstellt",
"no_players_available": "Keine Spieler:innen verfügbar",
"no_matches_played_yet": "Noch kein Spiel gespielt",
"no_players_created_yet": "Noch keine Spieler:in erstellt",
"no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden",
"no_players_selected": "Keine Spieler:innen ausgewählt",
@@ -104,21 +139,22 @@
"no_results_entered_yet": "Noch keine Ergebnisse eingetragen",
"no_second_match_available": "Kein zweites Spiel verfügbar",
"no_statistics_available": "Keine Statistiken verfügbar",
"no_teams_available": "Keine Teams verfügbar",
"no_statistics_created_yet": "Noch keine Statistiken erstellt",
"none": "Kein",
"none_group": "Keine",
"not_available": "Nicht verfügbar",
"not_part_of_any_group": "Noch keiner Gruppe hinzugefügt",
"place": "Platz",
"placement": "Platzierung",
"played_matches": "Gespielte Spiele",
"player_name": "Spieler:innenname",
"player_profile": "Spieler:in-Profil",
"players": "Spieler:innen",
"point": "Punkt",
"points": "Punkte",
"privacy_policy": "Datenschutzerklärung",
"quick_create": "Schnellzugriff",
"recent_matches": "Letzte Spiele",
"redistribute": "Neu verteilen",
"result": "Ergebnis",
"results": "Ergebnisse",
"ruleset": "Regelwerk",
@@ -128,31 +164,55 @@
"ruleset_single_loser": "Genau ein:e Verlierer:in wird bestimmt; der letzte Platz erhält die Strafe oder Konsequenz.",
"ruleset_single_winner": "Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.",
"save_changes": "Änderungen speichern",
"scope": "Bereich",
"search_for_groups": "Nach Gruppen suchen",
"search_for_players": "Nach Spieler:innen suchen",
"select_a_classifier": "Klassifikator auswählen",
"select_a_game": "Spielvorlage auswählen",
"select_a_group": "Gruppe auswählen",
"select_a_scope": "Bereich auswählen",
"select_a_timeframe": "Zeitraum auswählen",
"select_a_timeframe_for_which_data_will_be_filtered": "Wähle einen Zeitraum, für den die Daten gefiltert werden sollen",
"select_loser": "Verlierer:in wählen",
"select_the_filtered_games": "Wähle Spiele, nach denen gefiltert werden soll.",
"select_the_filtered_groups": "Wähle Gruppen, nach denen gefiltert werden soll.",
"select_the_filtered_timeframe": "Wähle einen Zeitraum, nach dem gefiltert werden soll.",
"select_winner": "Gewinner:in wählen",
"select_winners": "Gewinner:innen wählen",
"selected_games": "Ausgewählte Spielvorlagen",
"selected_groups": "Ausgewählte Gruppen",
"selected_players": "Ausgewählte Spieler:innen",
"set_name": "Name setzen",
"settings": "Einstellungen",
"single_loser": "Ein:e Verlierer:in",
"single_winner": "Ein:e Gewinner:in",
"statistic_type_average_score": "Durchschnittliche Punktzahl",
"statistic_type_best_score": "Beste Punktzahl",
"statistic_type_total_losses": "Niederlagen insgesamt",
"statistic_type_total_matches": "Spiele insgesamt",
"statistic_type_total_score": "Punktzahl insgesamt",
"statistic_type_total_wins": "Siege insgesamt",
"statistic_type_winrate": "Siegquote",
"statistic_type_worst_score": "Schlechteste Punktzahl",
"statistics": "Statistiken",
"stats": "Statistiken",
"successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt",
"team": "Team",
"team_match": "Teamspiel",
"teams": "Teams",
"there_are_no_games_matching_your_search": "Es gibt keine Spielvorlagen, die deiner Suche entspricht",
"there_is_no_group_matching_your_search": "Es gibt keine Gruppe, die deiner Suche entspricht",
"this_cannot_be_undone": "Dies kann nicht rückgängig gemacht werden.",
"tie": "Unentschieden",
"timeframe": "Zeitraum",
"today_at": "Heute um",
"total_losses": "Niederlagen insgesamt",
"total_matches": "Spiele insgesamt",
"total_score": "Punktzahl insgesamt",
"total_wins": "Siege insgesamt",
"undo": "Rückgängig",
"unknown_exception": "Unbekannter Fehler (siehe Konsole)",
"winner": "Gewinner:in",
"winners": "Gewinner:innen",
"winrate": "Siegquote",
"wins": "Siege",
"worst_score": "Schlechteste Punktzahl",
"yesterday_at": "Gestern um"
}

View File

@@ -1,6 +1,5 @@
{
"@@locale": "en",
"add_team": "Add Team",
"all_players": "All players",
"all_players_selected": "All players selected",
"amount_of_matches": "Amount of Matches",
@@ -19,14 +18,30 @@
"color_purple": "Purple",
"color_red": "Red",
"color_teal": "Teal",
"displayed_entries": "Displayed entries",
"color_yellow": "Yellow",
"could_not_add_player": "Could not add player",
"confirm": "Confirm",
"could_not_add_player": "Could not add player {playerName}",
"@could_not_add_player": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"create_game": "Create Game",
"create_group": "Create Group",
"create_match": "Create match",
"create_new_group": "Create new group",
"create_new_match": "Create new match",
"create_teams": "Create teams",
"create_statistic": "Create statistic",
"classifier": "Classifier",
"select_the_filtered_timeframe": "Select the timeframe you want to filter by.",
"select_the_filtered_games": "Select the games you want to filter by.",
"games": "Games",
"select_the_filtered_groups": "Select the groups you want to filter by.",
"scope": "Scope",
"timeframe": "Timeframe",
"created_on": "Created on",
"data": "Data",
"data_successfully_deleted": "Data successfully deleted",
@@ -44,13 +59,17 @@
}
}
},
"filter": "Filter",
"delete_group": "Delete Group",
"delete_match": "Delete Match",
"delete_player": "Delete player?",
"description": "Description",
"drag_to_set_placement": "Drag to set placement",
"edit_game": "Edit Game",
"edit_group": "Edit Group",
"edit_match": "Edit Match",
"edit_name": "Edit name",
"edit_player": "Edit player",
"enter_points": "Enter points",
"enter_results": "Enter Results",
"error_creating_group": "Error while creating group, please try again",
@@ -68,6 +87,7 @@
"group_name": "Group name",
"group_profile": "Group Profile",
"groups": "Groups",
"groups_part_of": "Groups part of",
"highest_score": "Highest Score",
"home": "Home",
"import_canceled": "Import canceled",
@@ -79,14 +99,16 @@
"legal_notice": "Legal Notice",
"licenses": "Licenses",
"live_edit_mode": "Live Edit Mode",
"loading": "Loading...",
"loser": "Loser",
"lowest_score": "Lowest Score",
"manage_members": "Manage Members",
"match_in_progress": "Match in progress...",
"match_name": "Match name",
"match_profile": "Match Profile",
"matches": "Matches",
"member": "Member",
"matches_part_of": "Matches part of",
"matches_played": "Matches played",
"matches_won": "Matches won",
"members": "Members",
"most_points": "Most Points",
"multiple_winners": "Multiple Winners",
@@ -96,7 +118,7 @@
"no_license_text_available": "No license text available",
"no_licenses_found": "No licenses found",
"no_matches_created_yet": "No matches created yet",
"no_players_available": "No players available",
"no_matches_played_yet": "No games played yet",
"no_players_created_yet": "No players created yet",
"no_players_found_with_that_name": "No players found with that name",
"no_players_selected": "No players selected",
@@ -104,21 +126,22 @@
"no_results_entered_yet": "No results entered yet",
"no_second_match_available": "No second match available",
"no_statistics_available": "No statistics available",
"no_teams_available": "No teams available",
"no_statistics_created_yet": "No statistics created yet",
"none": "None",
"none_group": "None",
"not_available": "Not available",
"not_part_of_any_group": "Not part of any group yet",
"place": "place",
"placement": "Placement",
"played_matches": "Played Matches",
"player_name": "Player name",
"player_profile": "Player Profile",
"players": "Players",
"point": "Point",
"points": "Points",
"privacy_policy": "Privacy Policy",
"quick_create": "Quick Create",
"recent_matches": "Recent Matches",
"redistribute": "Redistribute",
"results": "Results",
"ruleset": "Ruleset",
"ruleset_least_points": "Inverse scoring: the player with the fewest points wins.",
@@ -133,11 +156,26 @@
"select_winner": "Select Winner",
"select_winners": "Select Winners",
"selected_players": "Selected players",
"set_name": "Set name",
"settings": "Settings",
"select_a_classifier": "Select a classifier",
"select_a_game": "Select a game",
"select_a_group": "Select a group",
"select_a_scope": "Select a scope",
"select_a_timeframe": "Select a timeframe",
"single_loser": "Single Loser",
"single_winner": "Single Winner",
"statistics": "Statistics",
"stats": "Stats",
"selected_games": "Selected games",
"selected_groups": "Selected groups",
"average_score": "Average score",
"best_score": "Best score",
"total_losses": "Total losses",
"total_matches": "Total matches",
"total_score": "Total score",
"total_wins": "Total wins",
"worst_score": "Worst score",
"successfully_added_player": "Successfully added player {playerName}",
"@successfully_added_player": {
"description": "Success message when adding a player",
@@ -148,13 +186,16 @@
}
}
},
"team": "Team",
"team_match": "Team Match",
"teams": "Teams",
"there_are_no_games_matching_your_search": "There are no games matching your search",
"there_is_no_group_matching_your_search": "There is no group matching your search",
"this_cannot_be_undone": "This can't be undone.",
"tie": "Tie",
"all_time": "All time",
"last_180_days": "Last 180 days",
"last_30_days": "Last 30 days",
"last_7_days": "Last 7 days",
"last_90_days": "Last 90 days",
"last_year": "Last year",
"today_at": "Today at",
"undo": "Undo",
"unknown_exception": "Unknown Exception (see console)",

View File

@@ -98,12 +98,6 @@ abstract class AppLocalizations {
Locale('en'),
];
/// No description provided for @add_team.
///
/// In en, this message translates to:
/// **'Add Team'**
String get add_team;
/// No description provided for @all_players.
///
/// In en, this message translates to:
@@ -212,17 +206,29 @@ abstract class AppLocalizations {
/// **'Teal'**
String get color_teal;
/// No description provided for @displayed_entries.
///
/// In en, this message translates to:
/// **'Displayed entries'**
String get displayed_entries;
/// No description provided for @color_yellow.
///
/// In en, this message translates to:
/// **'Yellow'**
String get color_yellow;
/// No description provided for @confirm.
///
/// In en, this message translates to:
/// **'Confirm'**
String get confirm;
/// No description provided for @could_not_add_player.
///
/// In en, this message translates to:
/// **'Could not add player'**
String could_not_add_player(Object playerName);
/// **'Could not add player {playerName}'**
String could_not_add_player(String playerName);
/// No description provided for @create_game.
///
@@ -254,11 +260,53 @@ abstract class AppLocalizations {
/// **'Create new match'**
String get create_new_match;
/// No description provided for @create_teams.
/// No description provided for @create_statistic.
///
/// In en, this message translates to:
/// **'Create teams'**
String get create_teams;
/// **'Create statistic'**
String get create_statistic;
/// No description provided for @classifier.
///
/// In en, this message translates to:
/// **'Classifier'**
String get classifier;
/// No description provided for @select_the_filtered_timeframe.
///
/// In en, this message translates to:
/// **'Select the timeframe you want to filter by.'**
String get select_the_filtered_timeframe;
/// No description provided for @select_the_filtered_games.
///
/// In en, this message translates to:
/// **'Select the games you want to filter by.'**
String get select_the_filtered_games;
/// No description provided for @games.
///
/// In en, this message translates to:
/// **'Games'**
String get games;
/// No description provided for @select_the_filtered_groups.
///
/// In en, this message translates to:
/// **'Select the groups you want to filter by.'**
String get select_the_filtered_groups;
/// No description provided for @scope.
///
/// In en, this message translates to:
/// **'Scope'**
String get scope;
/// No description provided for @timeframe.
///
/// In en, this message translates to:
/// **'Timeframe'**
String get timeframe;
/// No description provided for @created_on.
///
@@ -320,6 +368,12 @@ abstract class AppLocalizations {
/// **'If you delete this game template, {count, plural, =1{1 match} other{{count} matches}} using this game template will also be deleted.'**
String delete_game_with_matches_warning(int count);
/// No description provided for @filter.
///
/// In en, this message translates to:
/// **'Filter'**
String get filter;
/// No description provided for @delete_group.
///
/// In en, this message translates to:
@@ -332,6 +386,12 @@ abstract class AppLocalizations {
/// **'Delete Match'**
String get delete_match;
/// No description provided for @delete_player.
///
/// In en, this message translates to:
/// **'Delete player?'**
String get delete_player;
/// No description provided for @description.
///
/// In en, this message translates to:
@@ -362,6 +422,18 @@ abstract class AppLocalizations {
/// **'Edit Match'**
String get edit_match;
/// No description provided for @edit_name.
///
/// In en, this message translates to:
/// **'Edit name'**
String get edit_name;
/// No description provided for @edit_player.
///
/// In en, this message translates to:
/// **'Edit player'**
String get edit_player;
/// No description provided for @enter_points.
///
/// In en, this message translates to:
@@ -464,6 +536,12 @@ abstract class AppLocalizations {
/// **'Groups'**
String get groups;
/// No description provided for @groups_part_of.
///
/// In en, this message translates to:
/// **'Groups part of'**
String get groups_part_of;
/// No description provided for @highest_score.
///
/// In en, this message translates to:
@@ -530,6 +608,12 @@ abstract class AppLocalizations {
/// **'Live Edit Mode'**
String get live_edit_mode;
/// No description provided for @loading.
///
/// In en, this message translates to:
/// **'Loading...'**
String get loading;
/// No description provided for @loser.
///
/// In en, this message translates to:
@@ -542,12 +626,6 @@ abstract class AppLocalizations {
/// **'Lowest Score'**
String get lowest_score;
/// No description provided for @manage_members.
///
/// In en, this message translates to:
/// **'Manage Members'**
String get manage_members;
/// No description provided for @match_in_progress.
///
/// In en, this message translates to:
@@ -572,11 +650,23 @@ abstract class AppLocalizations {
/// **'Matches'**
String get matches;
/// No description provided for @member.
/// No description provided for @matches_part_of.
///
/// In en, this message translates to:
/// **'Member'**
String get member;
/// **'Matches part of'**
String get matches_part_of;
/// No description provided for @matches_played.
///
/// In en, this message translates to:
/// **'Matches played'**
String get matches_played;
/// No description provided for @matches_won.
///
/// In en, this message translates to:
/// **'Matches won'**
String get matches_won;
/// No description provided for @members.
///
@@ -632,11 +722,11 @@ abstract class AppLocalizations {
/// **'No matches created yet'**
String get no_matches_created_yet;
/// No description provided for @no_players_available.
/// No description provided for @no_matches_played_yet.
///
/// In en, this message translates to:
/// **'No players available'**
String get no_players_available;
/// **'No games played yet'**
String get no_matches_played_yet;
/// No description provided for @no_players_created_yet.
///
@@ -680,11 +770,11 @@ abstract class AppLocalizations {
/// **'No statistics available'**
String get no_statistics_available;
/// No description provided for @no_teams_available.
/// No description provided for @no_statistics_created_yet.
///
/// In en, this message translates to:
/// **'No teams available'**
String get no_teams_available;
/// **'No statistics created yet'**
String get no_statistics_created_yet;
/// No description provided for @none.
///
@@ -704,6 +794,12 @@ abstract class AppLocalizations {
/// **'Not available'**
String get not_available;
/// No description provided for @not_part_of_any_group.
///
/// In en, this message translates to:
/// **'Not part of any group yet'**
String get not_part_of_any_group;
/// No description provided for @place.
///
/// In en, this message translates to:
@@ -728,6 +824,12 @@ abstract class AppLocalizations {
/// **'Player name'**
String get player_name;
/// No description provided for @player_profile.
///
/// In en, this message translates to:
/// **'Player Profile'**
String get player_profile;
/// No description provided for @players.
///
/// In en, this message translates to:
@@ -764,12 +866,6 @@ abstract class AppLocalizations {
/// **'Recent Matches'**
String get recent_matches;
/// No description provided for @redistribute.
///
/// In en, this message translates to:
/// **'Redistribute'**
String get redistribute;
/// No description provided for @results.
///
/// In en, this message translates to:
@@ -854,12 +950,48 @@ abstract class AppLocalizations {
/// **'Selected players'**
String get selected_players;
/// No description provided for @set_name.
///
/// In en, this message translates to:
/// **'Set name'**
String get set_name;
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @select_a_classifier.
///
/// In en, this message translates to:
/// **'Select a classifier'**
String get select_a_classifier;
/// No description provided for @select_a_game.
///
/// In en, this message translates to:
/// **'Select a game'**
String get select_a_game;
/// No description provided for @select_a_group.
///
/// In en, this message translates to:
/// **'Select a group'**
String get select_a_group;
/// No description provided for @select_a_scope.
///
/// In en, this message translates to:
/// **'Select a scope'**
String get select_a_scope;
/// No description provided for @select_a_timeframe.
///
/// In en, this message translates to:
/// **'Select a timeframe'**
String get select_a_timeframe;
/// No description provided for @single_loser.
///
/// In en, this message translates to:
@@ -884,30 +1016,66 @@ abstract class AppLocalizations {
/// **'Stats'**
String get stats;
/// No description provided for @selected_games.
///
/// In en, this message translates to:
/// **'Selected games'**
String get selected_games;
/// No description provided for @selected_groups.
///
/// In en, this message translates to:
/// **'Selected groups'**
String get selected_groups;
/// No description provided for @average_score.
///
/// In en, this message translates to:
/// **'Average score'**
String get average_score;
/// No description provided for @best_score.
///
/// In en, this message translates to:
/// **'Best score'**
String get best_score;
/// No description provided for @total_losses.
///
/// In en, this message translates to:
/// **'Total losses'**
String get total_losses;
/// No description provided for @total_matches.
///
/// In en, this message translates to:
/// **'Total matches'**
String get total_matches;
/// No description provided for @total_score.
///
/// In en, this message translates to:
/// **'Total score'**
String get total_score;
/// No description provided for @total_wins.
///
/// In en, this message translates to:
/// **'Total wins'**
String get total_wins;
/// No description provided for @worst_score.
///
/// In en, this message translates to:
/// **'Worst score'**
String get worst_score;
/// Success message when adding a player
///
/// In en, this message translates to:
/// **'Successfully added player {playerName}'**
String successfully_added_player(String playerName);
/// No description provided for @team.
///
/// In en, this message translates to:
/// **'Team'**
String get team;
/// No description provided for @team_match.
///
/// In en, this message translates to:
/// **'Team Match'**
String get team_match;
/// No description provided for @teams.
///
/// In en, this message translates to:
/// **'Teams'**
String get teams;
/// No description provided for @there_are_no_games_matching_your_search.
///
/// In en, this message translates to:
@@ -932,6 +1100,42 @@ abstract class AppLocalizations {
/// **'Tie'**
String get tie;
/// No description provided for @all_time.
///
/// In en, this message translates to:
/// **'All time'**
String get all_time;
/// No description provided for @last_180_days.
///
/// In en, this message translates to:
/// **'Last 180 days'**
String get last_180_days;
/// No description provided for @last_30_days.
///
/// In en, this message translates to:
/// **'Last 30 days'**
String get last_30_days;
/// No description provided for @last_7_days.
///
/// In en, this message translates to:
/// **'Last 7 days'**
String get last_7_days;
/// No description provided for @last_90_days.
///
/// In en, this message translates to:
/// **'Last 90 days'**
String get last_90_days;
/// No description provided for @last_year.
///
/// In en, this message translates to:
/// **'Last year'**
String get last_year;
/// No description provided for @today_at.
///
/// In en, this message translates to:

View File

@@ -8,9 +8,6 @@ import 'app_localizations.dart';
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get add_team => 'Team hinzufügen';
@override
String get all_players => 'Alle Spieler:innen';
@@ -65,11 +62,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get color_teal => 'Türkis';
@override
String get displayed_entries => 'Angezeigte Einträge';
@override
String get color_yellow => 'Gelb';
@override
String could_not_add_player(Object playerName) {
String get confirm => 'Bestätigen';
@override
String could_not_add_player(String playerName) {
return 'Spieler:in $playerName konnte nicht hinzugefügt werden';
}
@@ -89,7 +92,31 @@ class AppLocalizationsDe extends AppLocalizations {
String get create_new_match => 'Neues Spiel erstellen';
@override
String get create_teams => 'Teams erstellen';
String get create_statistic => 'Statistik erstellen';
@override
String get classifier => 'Klassifikator';
@override
String get select_the_filtered_timeframe =>
'Wähle einen Zeitraum, nach dem gefiltert werden soll.';
@override
String get select_the_filtered_games =>
'Wähle Spiele, nach denen gefiltert werden soll.';
@override
String get games => 'Spielvorlagen';
@override
String get select_the_filtered_groups =>
'Wähle Gruppen, nach denen gefiltert werden soll.';
@override
String get scope => 'Bereich';
@override
String get timeframe => 'Zeitraum';
@override
String get created_on => 'Erstellt am';
@@ -131,12 +158,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Wenn du diese Spielvorlage löschst, $_temp0 mit dieser Spielvorlage ebenfalls gelöscht.';
}
@override
String get filter => 'Filter';
@override
String get delete_group => 'Gruppe löschen';
@override
String get delete_match => 'Spiel löschen';
@override
String get delete_player => 'Spieler:in löschen';
@override
String get description => 'Beschreibung';
@@ -152,6 +185,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get edit_match => 'Gruppe bearbeiten';
@override
String get edit_name => 'Name ändern';
@override
String get edit_player => 'Spieler bearbeiten';
@override
String get enter_points => 'Punkte eingeben';
@@ -207,6 +246,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get groups => 'Gruppen';
@override
String get groups_part_of => 'Gruppen Teil von';
@override
String get highest_score => 'Höchste Punkte';
@@ -240,15 +282,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get live_edit_mode => 'Live-Bearbeitungsmodus';
@override
String get loading => 'Lädt...';
@override
String get loser => 'Verlierer:in';
@override
String get lowest_score => 'Niedrigste Punkte';
@override
String get manage_members => 'Mitglieder bearbeiten';
@override
String get match_in_progress => 'Spiel läuft...';
@@ -262,7 +304,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get matches => 'Spiele';
@override
String get member => 'Mitglied';
String get matches_part_of => 'Spiele Teil von';
@override
String get matches_played => 'Spiele gespielt';
@override
String get matches_won => 'Spiele gewonnen';
@override
String get members => 'Mitglieder';
@@ -292,7 +340,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get no_matches_created_yet => 'Noch keine Spiele erstellt';
@override
String get no_players_available => 'Keine Spieler:innen verfügbar';
String get no_matches_played_yet => 'Noch kein Spiel gespielt';
@override
String get no_players_created_yet => 'Noch keine Spieler:in erstellt';
@@ -317,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get no_statistics_available => 'Keine Statistiken verfügbar';
@override
String get no_teams_available => 'Keine Teams verfügbar';
String get no_statistics_created_yet => 'Noch keine Statistiken erstellt';
@override
String get none => 'Kein';
@@ -328,6 +376,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get not_available => 'Nicht verfügbar';
@override
String get not_part_of_any_group => 'Noch keiner Gruppe hinzugefügt';
@override
String get place => 'Platz';
@@ -340,6 +391,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get player_name => 'Spieler:innenname';
@override
String get player_profile => 'Spieler:in-Profil';
@override
String get players => 'Spieler:innen';
@@ -358,9 +412,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recent_matches => 'Letzte Spiele';
@override
String get redistribute => 'Neu verteilen';
@override
String get results => 'Ergebnisse';
@@ -408,9 +459,27 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get selected_players => 'Ausgewählte Spieler:innen';
@override
String get set_name => 'Name setzen';
@override
String get settings => 'Einstellungen';
@override
String get select_a_classifier => 'Klassifikator auswählen';
@override
String get select_a_game => 'Spielvorlage auswählen';
@override
String get select_a_group => 'Gruppe auswählen';
@override
String get select_a_scope => 'Bereich auswählen';
@override
String get select_a_timeframe => 'Zeitraum auswählen';
@override
String get single_loser => 'Ein:e Verlierer:in';
@@ -423,20 +492,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get stats => 'Statistiken';
@override
String get selected_games => 'Ausgewählte Spielvorlagen';
@override
String get selected_groups => 'Ausgewählte Gruppen';
@override
String get average_score => 'Durchschnittliche Punktzahl';
@override
String get best_score => 'Beste Punktzahl';
@override
String get total_losses => 'Niederlagen insgesamt';
@override
String get total_matches => 'Spiele insgesamt';
@override
String get total_score => 'Punktzahl insgesamt';
@override
String get total_wins => 'Siege insgesamt';
@override
String get worst_score => 'Schlechteste Punktzahl';
@override
String successfully_added_player(String playerName) {
return 'Spieler:in $playerName erfolgreich hinzugefügt';
}
@override
String get team => 'Team';
@override
String get team_match => 'Teamspiel';
@override
String get teams => 'Teams';
@override
String get there_are_no_games_matching_your_search =>
'Es gibt keine Spielvorlagen, die deiner Suche entspricht';
@@ -452,6 +539,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tie => 'Unentschieden';
@override
String get all_time => 'Gesamter Zeitraum';
@override
String get last_180_days => 'Letzte 180 Tage';
@override
String get last_30_days => 'Letzte 30 Tage';
@override
String get last_7_days => 'Letzte 7 Tage';
@override
String get last_90_days => 'Letzte 90 Tage';
@override
String get last_year => 'Letztes Jahr';
@override
String get today_at => 'Heute um';

View File

@@ -8,9 +8,6 @@ import 'app_localizations.dart';
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get add_team => 'Add Team';
@override
String get all_players => 'All players';
@@ -65,12 +62,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get color_teal => 'Teal';
@override
String get displayed_entries => 'Displayed entries';
@override
String get color_yellow => 'Yellow';
@override
String could_not_add_player(Object playerName) {
return 'Could not add player';
String get confirm => 'Confirm';
@override
String could_not_add_player(String playerName) {
return 'Could not add player $playerName';
}
@override
@@ -89,7 +92,31 @@ class AppLocalizationsEn extends AppLocalizations {
String get create_new_match => 'Create new match';
@override
String get create_teams => 'Create teams';
String get create_statistic => 'Create statistic';
@override
String get classifier => 'Classifier';
@override
String get select_the_filtered_timeframe =>
'Select the timeframe you want to filter by.';
@override
String get select_the_filtered_games =>
'Select the games you want to filter by.';
@override
String get games => 'Games';
@override
String get select_the_filtered_groups =>
'Select the groups you want to filter by.';
@override
String get scope => 'Scope';
@override
String get timeframe => 'Timeframe';
@override
String get created_on => 'Created on';
@@ -131,12 +158,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'If you delete this game template, $_temp0 using this game template will also be deleted.';
}
@override
String get filter => 'Filter';
@override
String get delete_group => 'Delete Group';
@override
String get delete_match => 'Delete Match';
@override
String get delete_player => 'Delete player?';
@override
String get description => 'Description';
@@ -152,6 +185,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get edit_match => 'Edit Match';
@override
String get edit_name => 'Edit name';
@override
String get edit_player => 'Edit player';
@override
String get enter_points => 'Enter points';
@@ -207,6 +246,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get groups => 'Groups';
@override
String get groups_part_of => 'Groups part of';
@override
String get highest_score => 'Highest Score';
@@ -240,15 +282,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get live_edit_mode => 'Live Edit Mode';
@override
String get loading => 'Loading...';
@override
String get loser => 'Loser';
@override
String get lowest_score => 'Lowest Score';
@override
String get manage_members => 'Manage Members';
@override
String get match_in_progress => 'Match in progress...';
@@ -262,7 +304,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get matches => 'Matches';
@override
String get member => 'Member';
String get matches_part_of => 'Matches part of';
@override
String get matches_played => 'Matches played';
@override
String get matches_won => 'Matches won';
@override
String get members => 'Members';
@@ -292,7 +340,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get no_matches_created_yet => 'No matches created yet';
@override
String get no_players_available => 'No players available';
String get no_matches_played_yet => 'No games played yet';
@override
String get no_players_created_yet => 'No players created yet';
@@ -317,7 +365,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get no_statistics_available => 'No statistics available';
@override
String get no_teams_available => 'No teams available';
String get no_statistics_created_yet => 'No statistics created yet';
@override
String get none => 'None';
@@ -328,6 +376,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get not_available => 'Not available';
@override
String get not_part_of_any_group => 'Not part of any group yet';
@override
String get place => 'place';
@@ -340,6 +391,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get player_name => 'Player name';
@override
String get player_profile => 'Player Profile';
@override
String get players => 'Players';
@@ -358,9 +412,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recent_matches => 'Recent Matches';
@override
String get redistribute => 'Redistribute';
@override
String get results => 'Results';
@@ -408,9 +459,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get selected_players => 'Selected players';
@override
String get set_name => 'Set name';
@override
String get settings => 'Settings';
@override
String get select_a_classifier => 'Select a classifier';
@override
String get select_a_game => 'Select a game';
@override
String get select_a_group => 'Select a group';
@override
String get select_a_scope => 'Select a scope';
@override
String get select_a_timeframe => 'Select a timeframe';
@override
String get single_loser => 'Single Loser';
@@ -423,20 +492,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get stats => 'Stats';
@override
String get selected_games => 'Selected games';
@override
String get selected_groups => 'Selected groups';
@override
String get average_score => 'Average score';
@override
String get best_score => 'Best score';
@override
String get total_losses => 'Total losses';
@override
String get total_matches => 'Total matches';
@override
String get total_score => 'Total score';
@override
String get total_wins => 'Total wins';
@override
String get worst_score => 'Worst score';
@override
String successfully_added_player(String playerName) {
return 'Successfully added player $playerName';
}
@override
String get team => 'Team';
@override
String get team_match => 'Team Match';
@override
String get teams => 'Teams';
@override
String get there_are_no_games_matching_your_search =>
'There are no games matching your search';
@@ -451,6 +538,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tie => 'Tie';
@override
String get all_time => 'All time';
@override
String get last_180_days => 'Last 180 days';
@override
String get last_30_days => 'Last 30 days';
@override
String get last_7_days => 'Last 7 days';
@override
String get last_90_days => 'Last 90 days';
@override
String get last_year => 'Last year';
@override
String get today_at => 'Today at';

View File

@@ -6,7 +6,7 @@ import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/group_view/group_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/statistics_view.dart';
import 'package:tallee/presentation/views/main_menu/statistics_view/statistics_view.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/navbar_item.dart';

View File

@@ -8,7 +8,7 @@ import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart';
import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart';
import 'package:tallee/presentation/widgets/player_selection.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
@@ -89,6 +89,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
Expanded(
child: PlayerSelection(
initialSelectedPlayers: initialSelectedPlayers,
onPlayerCreated: () => widget.onMembersChanged?.call(),
onChanged: (value) {
setState(() {
selectedPlayers = [...value];
@@ -96,24 +97,19 @@ class _CreateGroupViewState extends State<CreateGroupView> {
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedDialogButton(
buttonConstraints: const BoxConstraints(
minWidth: double.infinity,
minHeight: 50,
),
buttonText: widget.groupToEdit == null
? loc.create_group
: loc.edit_group,
buttonType: ButtonType.primary,
onPressed:
(_groupNameController.text.isEmpty ||
(selectedPlayers.length < 2))
? null
: _saveGroup,
),
CustomWidthButton(
text: widget.groupToEdit == null
? loc.create_group
: loc.edit_group,
sizeRelativeToWidth: 0.95,
buttonType: ButtonType.primary,
onPressed:
(_groupNameController.text.isEmpty ||
(selectedPlayers.length < 2))
? null
: _saveGroup,
),
const SizedBox(height: 20),
],
),
),
@@ -139,6 +135,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
if (!mounted) return;
if (success) {
widget.onMembersChanged?.call();
await HapticFeedback.successNotification();
if (mounted) {
Navigator.pop(context, updatedGroup);
@@ -162,7 +159,6 @@ class _CreateGroupViewState extends State<CreateGroupView> {
final success = await db.groupDao.addGroup(
group: Group(name: groupName, members: selectedPlayers),
);
return success;
}

View File

@@ -150,6 +150,7 @@ class _GroupDetailViewState extends State<GroupDetailView> {
return TextIconTile(
text: member.name,
suffixText: getNameCountText(member),
iconEnabled: false,
);
}).toList(),
),

View File

@@ -77,6 +77,7 @@ class _GroupViewState extends State<GroupView> {
);
}
return GroupTile(
onPlayerChanged: loadGroups,
group: groups[index],
onTap: () async {
await Navigator.push(
@@ -106,13 +107,10 @@ class _GroupViewState extends State<GroupView> {
context,
adaptivePageRoute(
builder: (context) {
return const CreateGroupView();
return CreateGroupView(onMembersChanged: loadGroups);
},
),
);
setState(() {
loadGroups();
});
},
),
),

View File

@@ -51,9 +51,6 @@ class _ChooseGameViewState extends State<ChooseGameView> {
/// Games filtered according to the current search query
late List<Game> filteredGames;
List<Game> get games =>
widget.games..sort((a, b) => a.name.compareTo(b.name));
@override
void initState() {
db = Provider.of<AppDatabase>(context, listen: false);
@@ -62,7 +59,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
selectedGameId = widget.initialGameId;
// Start with all games visible
filteredGames = List<Game>.from(games);
filteredGames = List<Game>.from(widget.games);
super.initState();
}
@@ -80,7 +77,9 @@ class _ChooseGameViewState extends State<ChooseGameView> {
Navigator.of(context).pop(
selectedGameId == ''
? null
: games.firstWhere((game) => game.id == selectedGameId),
: widget.games.firstWhere(
(game) => game.id == selectedGameId,
),
);
},
),
@@ -100,7 +99,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
);
if (result != null && result.game != null) {
setState(() {
games.insert(0, result.game);
widget.games.insert(0, result.game);
});
_refreshFromSource();
}
@@ -140,7 +139,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
child: Visibility(
visible: filteredGames.isNotEmpty,
replacement: Visibility(
visible: games.isNotEmpty,
visible: widget.games.isNotEmpty,
replacement: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
@@ -161,8 +160,11 @@ class _ChooseGameViewState extends State<ChooseGameView> {
return GameTile(
title: game.name,
description: game.description,
subtitle: translateRulesetToString(game.ruleset, context),
badgeColor: getColorFromGameColor(game.color),
badgeText: translateRulesetToString(
game.ruleset,
context,
),
badgeColor: getColorFromAppColor(game.color),
isHighlighted: selectedGameId == game.id,
onTap: () async {
setState(() {
@@ -188,7 +190,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
);
if (result != null && result.game != null) {
// Find the index in the original list to mutate
final originalIndex = games.indexWhere(
final originalIndex = widget.games.indexWhere(
(g) => g.id == game.id,
);
if (originalIndex == -1) {
@@ -200,12 +202,12 @@ class _ChooseGameViewState extends State<ChooseGameView> {
if (selectedGameId == game.id) {
selectedGameId = '';
}
games.removeAt(originalIndex);
widget.games.removeAt(originalIndex);
widget.onGamesUpdated?.call();
});
} else {
setState(() {
games[originalIndex] = result.game;
widget.games[originalIndex] = result.game;
});
}
_refreshFromSource();
@@ -227,13 +229,13 @@ class _ChooseGameViewState extends State<ChooseGameView> {
final q = query.toLowerCase().trim();
if (q.isEmpty) {
setState(() {
filteredGames = List<Game>.from(games);
filteredGames = List<Game>.from(widget.games);
});
return;
}
setState(() {
filteredGames = games.where((game) {
filteredGames = widget.games.where((game) {
final name = game.name.toLowerCase();
final description = game.description.toLowerCase();
return name.contains(q) || description.contains(q);

View File

@@ -90,7 +90,7 @@ class _CreateGameViewState extends State<CreateGameView> {
AppColor.values.length,
(index) => (
AppColor.values[index],
translateGameColorToString(AppColor.values[index], context),
translateAppColorToString(AppColor.values[index], context),
),
);
@@ -117,7 +117,6 @@ class _CreateGameViewState extends State<CreateGameView> {
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
title: Text(isEditing ? loc.edit_game : loc.create_game),
actions: [
@@ -468,7 +467,7 @@ class _CreateGameViewState extends State<CreateGameView> {
height: 16,
margin: const EdgeInsets.only(left: 12),
decoration: BoxDecoration(
color: getColorFromGameColor(
color: getColorFromAppColor(
_colors[index].$1,
),
shape: BoxShape.circle,
@@ -502,13 +501,13 @@ class _CreateGameViewState extends State<CreateGameView> {
width: 16,
height: 16,
decoration: BoxDecoration(
color: getColorFromGameColor(selectedColor!),
color: getColorFromAppColor(selectedColor!),
shape: BoxShape.circle,
),
),
Padding(
padding: const EdgeInsets.only(right: 5),
child: Text(translateGameColorToString(selectedColor!, context)),
child: Text(translateAppColorToString(selectedColor!, context)),
),
Transform.rotate(
angle: pi / 2,

View File

@@ -12,9 +12,8 @@ import 'package:tallee/data/models/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/choose_game_view.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/choose_group_view.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_teams/create_teams_view.dart';
import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart';
import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart';
import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart';
import 'package:tallee/presentation/widgets/player_selection.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
import 'package:tallee/presentation/widgets/tiles/choose_tile.dart';
@@ -60,7 +59,6 @@ class _CreateMatchViewState extends State<CreateMatchView> {
Group? selectedGroup;
Game? selectedGame;
bool isTeamMatch = false;
List<Player> selectedPlayers = [];
/// GlobalKey for ScaffoldMessenger to show snackbars
@@ -137,7 +135,24 @@ class _CreateMatchViewState extends State<CreateMatchView> {
trailing: selectedGame == null
? Text(loc.none_group)
: Text(selectedGame!.name),
onPressed: () async => await onChoosingGame(),
onPressed: () async {
selectedGame = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGameView(
games: gamesList,
initialGameId: selectedGame?.id ?? '',
onGamesUpdated: widget.onMatchesUpdated,
),
),
);
setState(() {
if (selectedGame != null) {
hintText = selectedGame!.name;
} else {
hintText = loc.match_name;
}
});
},
),
// Group selection tile.
@@ -146,25 +161,42 @@ class _CreateMatchViewState extends State<CreateMatchView> {
trailing: selectedGroup == null
? Text(loc.none_group)
: Text(selectedGroup!.name),
onPressed: () async => onChoosingGroup(),
),
onPressed: () async {
// Remove all players from the previously selected group from
// the selected players list, in case the user deselects the
// group or selects a different group.
selectedPlayers.removeWhere(
(player) =>
selectedGroup?.members.any(
(member) => member.id == player.id,
) ??
false,
);
selectedGroup = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGroupView(
groups: groupsList,
initialGroupId: selectedGroup?.id ?? '',
),
),
);
if (!isEditMode())
ChooseTile(
title: loc.team_match,
trailing: Switch.adaptive(
activeTrackColor: CustomTheme.primaryColor,
padding: const EdgeInsets.symmetric(vertical: -15),
value: isTeamMatch,
onChanged: (value) => setState(() => isTeamMatch = value),
),
),
setState(() {
if (selectedGroup != null) {
setState(() {
selectedPlayers += [...selectedGroup!.members];
});
}
});
},
),
// Player selection widget.
Expanded(
child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'),
initialSelectedPlayers: selectedPlayers,
onPlayerCreated: () => widget.onMatchesUpdated?.call(),
onChanged: (value) {
setState(() {
selectedPlayers = value;
@@ -175,21 +207,15 @@ class _CreateMatchViewState extends State<CreateMatchView> {
),
// Create or save button.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedDialogButton(
buttonConstraints: const BoxConstraints(
minWidth: double.infinity,
minHeight: 50,
),
buttonType: ButtonType.primary,
onPressed: isSubmitButtonEnabled()
? () {
submitButtonNavigation(context);
}
: null,
buttonText: buttonText,
),
CustomWidthButton(
text: buttonText,
sizeRelativeToWidth: 0.95,
buttonType: ButtonType.primary,
onPressed: _enableCreateGameButton()
? () {
buttonNavigation(context);
}
: null,
),
],
),
@@ -202,86 +228,12 @@ class _CreateMatchViewState extends State<CreateMatchView> {
return widget.matchToEdit != null;
}
// If a match was provided to the view, this method prefills the input fields
void prefillMatchDetails() {
final match = widget.matchToEdit!;
_matchNameController.text = match.name;
selectedPlayers = match.players;
selectedGame = match.game;
if (match.group != null) {
selectedGroup = match.group;
}
}
Future<void> onChoosingGame() async {
selectedGame = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGameView(
games: gamesList,
initialGameId: selectedGame?.id ?? '',
onGamesUpdated: widget.onMatchesUpdated,
),
),
);
setState(() {
if (selectedGame != null) {
hintText = selectedGame!.name;
} else {
hintText = AppLocalizations.of(context).match_name;
}
});
}
Future<void> onChoosingGroup() async {
// Remove all players from the previously selected group from
// the selected players list, in case the user deselects the
// group or selects a different group.
selectedPlayers.removeWhere(
(player) =>
selectedGroup?.members.any((member) => member.id == player.id) ??
false,
);
selectedGroup = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGroupView(
groups: groupsList,
initialGroupId: selectedGroup?.id ?? '',
),
),
);
setState(() {
if (selectedGroup != null) {
setState(() {
selectedPlayers += [...selectedGroup!.members];
});
}
});
}
// If none of the selected players are from the currently selected group,
// the group is also deselected.
Future<void> removeGroupWhenNoMemberLeft() async {
if (selectedGroup == null) return;
if (!selectedPlayers.any(
(player) =>
selectedGroup!.members.any((member) => member.id == player.id),
)) {
setState(() {
selectedGroup = null;
});
}
}
/// Determines whether the "Create Match" button should be enabled.
///
/// Returns `true` if:
/// - A game is selected AND
/// - Either a group is selected OR at least 2 players are selected.
bool isSubmitButtonEnabled() {
bool _enableCreateGameButton() {
return ((selectedGroup != null || selectedPlayers.length > 1) &&
selectedGame != null);
}
@@ -290,35 +242,20 @@ class _CreateMatchViewState extends State<CreateMatchView> {
///
/// If a match is being edited, updates the match in the database.
/// Otherwise, creates a new match and navigates to the MatchResultView.
void submitButtonNavigation(BuildContext context) async {
void buttonNavigation(BuildContext context) async {
if (isEditMode()) {
await updateMatch();
if (context.mounted) {
Navigator.pop(context);
}
}
final match = await createMatch();
if (isTeamMatch) {
if (context.mounted) {
Navigator.push(
context,
adaptivePageRoute(
fullscreenDialog: !isTeamMatch,
builder: (context) => CreateTeamsView(
match: match,
onWinnerChanged: widget.onWinnerChanged,
),
),
);
}
} else {
final match = await createMatch();
if (context.mounted) {
Navigator.pushReplacement(
context,
adaptivePageRoute(
fullscreenDialog: !isTeamMatch,
fullscreenDialog: true,
builder: (context) => MatchResultView(
match: match,
onWinnerChanged: widget.onWinnerChanged,
@@ -391,12 +328,36 @@ class _CreateMatchViewState extends State<CreateMatchView> {
createdAt: DateTime.now(),
group: selectedGroup,
players: selectedPlayers,
isTeamMatch: isTeamMatch,
game: selectedGame!,
);
// Team matches are saved in OrganizeTeamsView
if (!isTeamMatch) await db.matchDao.addMatch(match: match);
await db.matchDao.addMatch(match: match);
return match;
}
// If a match was provided to the view, this method prefills the input fields
void prefillMatchDetails() {
final match = widget.matchToEdit!;
_matchNameController.text = match.name;
selectedPlayers = match.players;
selectedGame = match.game;
if (match.group != null) {
selectedGroup = match.group;
}
}
// If none of the selected players are from the currently selected group,
// the group is also deselected.
Future<void> removeGroupWhenNoMemberLeft() async {
if (selectedGroup == null) return;
if (!selectedPlayers.any(
(player) =>
selectedGroup!.members.any((member) => member.id == player.id),
)) {
setState(() {
selectedGroup = null;
});
}
}
}

View File

@@ -1,190 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/team.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_teams/manage_members_view.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/tiles/team_creation_tile.dart';
class CreateTeamsView extends StatefulWidget {
const CreateTeamsView({super.key, required this.match, this.onWinnerChanged});
final Match match;
final VoidCallback? onWinnerChanged;
@override
State<CreateTeamsView> createState() => _CreateTeamsViewState();
}
class _CreateTeamsViewState extends State<CreateTeamsView> {
final Random random = Random();
List<Player> get matchPlayers => widget.match.players;
late List<Team> teams;
late List<TextEditingController> nameController;
final int initialTeamCount = 2;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final loc = AppLocalizations.of(context);
// Init the teams
teams = List.generate(
initialTeamCount,
(index) => Team(
name: '${loc.team} ${index + 1}',
color: getTeamColor(index),
members: [],
),
);
// Init the controllers
nameController = teams.map(getNewController).toList();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(title: Text(loc.create_teams)),
body: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: ListView.builder(
padding: const EdgeInsets.only(top: 12, bottom: 96),
itemCount: teams.length,
itemBuilder: (context, index) {
return TeamCreationTile(
color: teams[index].color,
controller: nameController[index],
hintText: '${loc.team} ${index + 1}',
onDelete: teams.length <= 2 ? null : () => removeTeam(index),
onColorSelection: (color) {
setState(() {
teams[index] = teams[index].copyWith(color: color);
});
},
);
},
),
),
// Button row
Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Add new team
MainMenuButton(
icon: Icons.add,
text: loc.add_team,
onPressed: teams.length >= widget.match.players.length
? null
: addTeam,
),
const SizedBox(width: 15),
// Confirm teams
MainMenuButton(
icon: Icons.arrow_forward_sharp,
onPressed: teams.length >= 2
? () {
final match = widget.match.copyWith(teams: teams);
Navigator.push(
context,
adaptivePageRoute(
builder: (context) => ManageMembersView(
match: match,
onWinnerChanged: widget.onWinnerChanged,
),
),
);
}
: null,
),
],
),
),
],
),
);
}
/// Creates a new team with a default name and color based on the current number
Team getNewTeam() {
final loc = AppLocalizations.of(context);
return Team(
name: '${loc.team} ${teams.length + 1}',
color: getTeamColor(teams.length),
members: [],
);
}
/// Builds a [TextEditingController] for the given team and sets up a listener
/// to update the team's name whenever the text changes.
TextEditingController getNewController(Team team) {
final textController = TextEditingController(text: team.name);
textController.addListener(() {
final index = teams.indexWhere((t) => t.id == team.id);
if (index == -1) return;
teams[index] = teams[index].copyWith(name: textController.text);
});
return textController;
}
/// Adds a new team to the list of teams, creates a corresponding controller,
/// and redistributes the players among all teams.
void addTeam() {
setState(() {
final newTeam = getNewTeam();
teams.add(newTeam);
nameController.add(getNewController(newTeam));
});
}
/// Removes the team with the given index. If there are less than 2 teams the
/// removed team gets replaced with a new one
void removeTeam(int index) {
final loc = AppLocalizations.of(context);
setState(() {
teams.removeAt(index);
final removedController = nameController.removeAt(index);
removedController.dispose();
// Update index-based team names and default colors
for (int i = 0; i < nameController.length; i++) {
if (nameController[i].text.contains(
RegExp('^${RegExp.escape(loc.team)} \\d+\$'),
)) {
nameController[i].text = '${loc.team} ${i + 1}';
// Reset color to default if it was based on the index
final previousIndex = i < index ? i : i + 1;
if (teams[i].color == getTeamColor(previousIndex)) {
teams[i] = teams[i].copyWith(color: getTeamColor(i));
}
}
}
});
}
@override
void dispose() {
for (final c in nameController) {
c.dispose();
}
super.dispose();
}
}

View File

@@ -1,306 +0,0 @@
import 'dart:core' hide Match;
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_numeric_text/flutter_numeric_text.dart';
import 'package:fluttericon/rpg_awesome_icons.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/team.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_list_tile.dart';
/// Displays the given [teams] as a flat reorderable list where every team is
/// preceded by a header row and followed by its members. Members can be
/// dragged across team boundaries to be reassigned to another team.
class ManageMembersView extends StatefulWidget {
const ManageMembersView({
super.key,
required this.match,
required this.onWinnerChanged,
});
final Match match;
final VoidCallback? onWinnerChanged;
@override
State<ManageMembersView> createState() => _ManageMembersViewState();
}
class _ManageMembersViewState extends State<ManageMembersView> {
late AppDatabase db;
List<Team> get teams => widget.match.teams!;
@override
void initState() {
super.initState();
db = Provider.of<AppDatabase>(context, listen: false);
redistributePlayers();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(title: Text(loc.manage_members)),
body: Stack(
alignment: AlignmentDirectional.center,
children: [
Positioned.fill(
child: ReorderableListView.builder(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 96),
buildDefaultDragHandles: false,
itemCount: allItemsCount,
onReorderItem: onReorderItem,
proxyDecorator: (child, index, animation) =>
Material(type: MaterialType.transparency, child: child),
itemBuilder: (context, index) {
final teamIndex = teamIndexForFlat(index);
final memberIndex = memberIndexForFlat(index, teamIndex);
final team = teams[teamIndex];
if (memberIndex == -1) {
return buildTeamTile(team: team);
}
final player = team.members[memberIndex];
return ReorderableDelayedDragStartListener(
key: ValueKey('player_${player.id}'),
index: index,
child: TextIconListTile(
text: player.name,
suffixText: getNameCountText(player),
icon: Icons.drag_handle,
),
);
},
),
),
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MainMenuButton(
onPressed: () => setState(() {
redistributePlayers();
}),
icon: Icons.cached,
),
const SizedBox(width: 16),
MainMenuButton(
onPressed: allTeamsHaveMembers
? () async => submitMatch()
: null,
text: loc.create_match,
icon: RpgAwesome.clovers_card,
),
],
),
),
],
),
);
}
Widget buildTeamTile({required Team team}) {
final color = getColorFromGameColor(team.color);
final loc = AppLocalizations.of(context);
final length = team.members.length;
final memberText = length == 1 ? loc.member : loc.members;
return Padding(
key: ValueKey(team.id),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: Row(
children: [
// Color circle
Container(
width: 14,
height: 14,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 10),
// Team name
Expanded(
child: Text(
team.name,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 17,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
// Member length
SizedBox(
width: 150,
child: NumericText(
'$length $memberText',
duration: const Duration(milliseconds: 200),
maxLines: 1,
textAlign: TextAlign.end,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: CustomTheme.hintColor,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
// Iterates through all teams and redistributes players randomly and
// as evenly as possible.
void redistributePlayers() {
for (final team in teams) {
team.members.clear();
}
var matchPlayers = widget.match.players;
Random random = Random();
if (matchPlayers.isEmpty || teams.isEmpty) {
return;
}
final shuffledPlayers = [...matchPlayers]..shuffle(random);
for (int i = 0; i < shuffledPlayers.length; i++) {
final teamIndex = i % teams.length;
teams[teamIndex].members.add(shuffledPlayers[i]);
}
}
/// Handles moving a member from one team to another
void onReorderItem(int oldIndex, int newIndex) {
final sourceTeamIndex = teamIndexForFlat(oldIndex);
final sourceMemberIndex = memberIndexForFlat(oldIndex, sourceTeamIndex);
// Headers themselves can't be reordered.
if (sourceMemberIndex == -1) return;
// When moving down, the target index is shifted by 1
// because the item is removed first.
var targetIndex = newIndex;
if (newIndex > oldIndex) targetIndex -= 1;
targetIndex = targetIndex.clamp(0, allItemsCount - 1);
// Resolve target location based on the item currently
// at targetIndex before the move.
int destTeamIndex;
int insertPositionInTeam;
if (targetIndex >= allItemsCount - 1 && newIndex >= allItemsCount) {
// dropped at the very end, append to the last team.
destTeamIndex = teams.length - 1;
insertPositionInTeam = teams[destTeamIndex].members.length;
} else {
destTeamIndex = teamIndexForFlat(targetIndex);
final anchorMemberIndex = memberIndexForFlat(targetIndex, destTeamIndex);
if (anchorMemberIndex == -1) {
// dropped on a header, direction decides which team the player gets added
// if moving down, insert as first member of that team.
// if moving UP, append to the previous team.
final isMovingDown = newIndex > oldIndex;
if (isMovingDown) {
insertPositionInTeam = 0;
} else {
final previousTeamIndex = destTeamIndex - 1;
if (previousTeamIndex < 0) {
// above the very first header, stay at top of team 0.
insertPositionInTeam = 0;
} else {
destTeamIndex = previousTeamIndex;
insertPositionInTeam = teams[destTeamIndex].members.length;
}
}
} else {
insertPositionInTeam = anchorMemberIndex;
}
}
setState(() {
final sourceMembers = teams[sourceTeamIndex].members;
final player = sourceMembers.removeAt(sourceMemberIndex);
// Adjust insert index if removed from before the insert point in the
// same team.
if (sourceTeamIndex == destTeamIndex &&
insertPositionInTeam > sourceMembers.length) {
insertPositionInTeam = sourceMembers.length;
}
teams[destTeamIndex].members.insert(insertPositionInTeam, player);
});
}
/// Total players + teams length
int get allItemsCount {
var count = 0;
for (final team in teams) {
count += 1 + team.members.length;
}
return count;
}
/// Returns the index of the team that owns the flat-list item at [flatIndex].
int teamIndexForFlat(int flatIndex) {
var remaining = flatIndex;
for (var i = 0; i < teams.length; i++) {
final size = 1 + teams[i].members.length;
if (remaining < size) return i;
remaining -= size;
}
return teams.length - 1;
}
/// Returns the member index within its team, or `-1` if the item at
/// [flatIndex] is the team header.
int memberIndexForFlat(int flatIndex, int teamIndex) {
var offset = 0;
for (var i = 0; i < teamIndex; i++) {
offset += 1 + teams[i].members.length;
}
// offset now points to the header of [teamIndex]. Anything beyond is a
// member of that team.
final localIndex = flatIndex - offset;
return localIndex == 0 ? -1 : localIndex - 1;
}
bool get allTeamsHaveMembers =>
teams.every((team) => team.members.isNotEmpty);
void submitMatch() async {
final match = widget.match;
await db.matchDao.addMatch(match: match);
if (mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (_) => MatchResultView(
match: match,
onWinnerChanged: widget.onWinnerChanged,
),
),
(route) => route.isFirst,
);
}
}
}

View File

@@ -1,89 +0,0 @@
import 'package:flutter/material.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/team.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart';
class LiveEditView extends StatefulWidget {
const LiveEditView({super.key, required this.match});
final Match match;
@override
State<LiveEditView> createState() => _LiveEditViewState();
}
class _LiveEditViewState extends State<LiveEditView> {
List<Team> get allTeams =>
(widget.match.teams ?? [])..sort((a, b) => a.name.compareTo(b.name));
List<Player> get allPlayers =>
widget.match.players..sort((a, b) => a.name.compareTo(b.name));
List<int> scores = [];
@override
void initState() {
super.initState();
if (widget.match.isTeamMatch) {
scores = List.generate(
allTeams.length,
(index) => allTeams[index].score ?? 0,
);
} else {
scores = List.generate(
allPlayers.length,
(index) => widget.match.scores[allPlayers[index].id]?.score ?? 0,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.match.name),
leading: HapticIconButton(
onPressed: () => Navigator.pop(context, scores),
icon: const Icon(Icons.close),
),
),
body: Column(
children: [
Expanded(child: buildLiveEditWidget(widget.match.isTeamMatch)),
],
),
);
}
Widget buildLiveEditWidget(bool isTeamMatch) {
if (isTeamMatch) {
return ListView.builder(
itemCount: allTeams.length,
itemBuilder: (context, index) {
return LiveEditListTile(
title: allTeams[index].name,
onChanged: (value) {
scores[index] = value;
},
value: scores[index],
);
},
);
} else {
return ListView.builder(
itemCount: allPlayers.length,
itemBuilder: (context, index) {
return LiveEditListTile(
title: allPlayers[index].name,
onChanged: (value) {
setState(() {
scores[index] = value;
});
},
value: scores[index],
);
},
);
}
}
}

View File

@@ -13,7 +13,6 @@ import 'package:tallee/presentation/views/main_menu/match_view/create_match/crea
import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/cards/team_card.dart';
import 'package:tallee/presentation/widgets/colored_icon_container.dart';
import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart';
import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart';
@@ -44,13 +43,13 @@ class MatchDetailView extends StatefulWidget {
class _MatchDetailViewState extends State<MatchDetailView> {
late final AppDatabase db;
late Match localMatch;
late Match match;
@override
void initState() {
super.initState();
db = Provider.of<AppDatabase>(context, listen: false);
localMatch = widget.match;
match = widget.match;
}
@override
@@ -84,7 +83,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
).then((confirmed) async {
if (confirmed! && context.mounted) {
await db.matchDao.deleteMatch(matchId: localMatch.id);
await db.matchDao.deleteMatch(matchId: match.id);
if (!context.mounted) return;
Navigator.pop(context);
widget.onMatchUpdate.call();
@@ -118,7 +117,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
// Match Name
Text(
localMatch.name,
match.name,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -130,7 +129,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
// Creation Date
Text(
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(localMatch.createdAt)}',
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(match.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: CustomTheme.textColor,
@@ -140,14 +139,14 @@ class _MatchDetailViewState extends State<MatchDetailView> {
const SizedBox(height: 10),
// Group Name
if (localMatch.group != null) ...[
if (match.group != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.group),
const SizedBox(width: 8),
Text(
'${localMatch.group!.name}${getExtraPlayerCount(localMatch)}',
'${match.group!.name}${getExtraPlayerCount(match)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
@@ -155,60 +154,25 @@ class _MatchDetailViewState extends State<MatchDetailView> {
const SizedBox(height: 20),
],
// Teams or Players
if (localMatch.isTeamMatch) ...[
// Teams
InfoTile(
title: loc.teams,
icon: Icons.scoreboard,
horizontalAlignment: CrossAxisAlignment.start,
content:
localMatch.teams != null && localMatch.teams!.isNotEmpty
? Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: (localMatch.teams ?? []).map((team) {
return TeamCard(team: team);
}).toList(),
)
: Text(
loc.no_teams_available,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
// Players
InfoTile(
title: loc.players,
icon: Icons.people,
horizontalAlignment: CrossAxisAlignment.start,
content: Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: match.players.map((player) {
return TextIconTile(
text: player.name,
suffixText: getNameCountText(player),
iconEnabled: false,
);
}).toList(),
),
] else ...[
// Players
InfoTile(
title: loc.players,
icon: Icons.people,
horizontalAlignment: CrossAxisAlignment.start,
content: localMatch.players.isNotEmpty
? Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: localMatch.players.map((player) {
return TextIconTile(
text: player.name,
suffixText: getNameCountText(player),
);
}).toList(),
)
: Text(
loc.no_players_available,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
),
],
),
const SizedBox(height: 15),
// Game
@@ -222,12 +186,12 @@ class _MatchDetailViewState extends State<MatchDetailView> {
horizontal: 8,
),
child: GameLabel(
title: localMatch.game.name,
title: match.game.name,
description: translateRulesetToString(
localMatch.game.ruleset,
match.game.ruleset,
context,
),
color: localMatch.game.color,
color: match.game.color,
),
),
),
@@ -258,7 +222,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
adaptivePageRoute(
fullscreenDialog: true,
builder: (context) => CreateMatchView(
matchToEdit: localMatch,
matchToEdit: match,
onMatchUpdated: onMatchUpdated,
),
),
@@ -274,10 +238,12 @@ class _MatchDetailViewState extends State<MatchDetailView> {
adaptivePageRoute(
fullscreenDialog: true,
builder: (context) => MatchResultView(
match: localMatch,
onWinnerChanged: () async {
match: match,
onWinnerChanged: () {
widget.onMatchUpdate.call();
await updateScoresForCurrentMatch();
setState(() {
updateScoresForCurrentMatch();
});
},
),
),
@@ -297,7 +263,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
/// updates the match in this view
void onMatchUpdated(Match editedMatch) {
setState(() {
localMatch = editedMatch;
match = editedMatch;
});
widget.onMatchUpdate.call();
}
@@ -318,113 +284,95 @@ class _MatchDetailViewState extends State<MatchDetailView> {
/// Returns the result row for single winner/loser rulesets or a placeholder
/// if no result is entered yet
List<Widget> getSingleResultRow(AppLocalizations loc) {
final ruleset = localMatch.game.ruleset;
if (match.mvp.isNotEmpty) {
final ruleset = match.game.ruleset;
if (localMatch.mvp.isNotEmpty || localMatch.mvt.isNotEmpty) {
// Single winner/loser, multiple winner
final names = localMatch.isTeamMatch
? localMatch.mvt.map((t) => t.name).toList()
: localMatch.mvp.map((p) => p.name).toList();
final mvpNames = names.length == 1 ? names.first : names.join(', ');
final label = ruleset == Ruleset.singleWinner
? loc.winner
: ruleset == Ruleset.singleLoser
? loc.loser
: loc.winners;
return [
Text(
label,
style: const TextStyle(fontSize: 16, color: CustomTheme.textColor),
),
SizedBox(
width: 200,
child: Text(
mvpNames,
textAlign: TextAlign.end,
if (ruleset == Ruleset.singleWinner || ruleset == Ruleset.singleLoser) {
return [
Text(
ruleset == Ruleset.singleWinner ? loc.winner : loc.loser,
style: const TextStyle(fontSize: 16, color: CustomTheme.textColor),
),
Text(
match.mvp.first.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: CustomTheme.primaryColor,
),
),
),
];
} else {
// No result yet
return [
Text(
loc.no_results_entered_yet,
style: const TextStyle(fontSize: 14, color: CustomTheme.textColor),
),
];
];
} else if (match.game.ruleset == Ruleset.multipleWinners) {
return [
Text(
loc.winners,
style: const TextStyle(fontSize: 16, color: CustomTheme.textColor),
),
Flexible(
child: Container(
padding: const EdgeInsets.only(left: 10),
child: Text(
match.mvp.map((player) => player.name).join(', '),
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: CustomTheme.primaryColor,
),
),
),
),
];
}
}
// No results yet
return [
Text(
loc.no_results_entered_yet,
style: const TextStyle(fontSize: 14, color: CustomTheme.textColor),
),
];
}
/// Returns the result widget for scores or placement
Widget getMultiResultRows(AppLocalizations loc) {
List<(String, int)> scores = getSortedScores();
List<(String, int)> playerScores = [];
for (var player in match.players) {
int score = match.scores[player.id]?.score ?? 0;
playerScores.add((player.name, score));
}
final ruleset = match.game.ruleset;
if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) {
playerScores.sort((a, b) => b.$2.compareTo(a.$2));
} else if (ruleset == Ruleset.lowestScore) {
playerScores.sort((a, b) => a.$2.compareTo(b.$2));
}
return Column(
children: [
for (var i = 0; i < scores.length; i++)
for (var i = 0; i < playerScores.length; i++)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
scores[i].$1,
playerScores[i].$1,
style: const TextStyle(
fontSize: 16,
color: CustomTheme.textColor,
),
),
getResultValueText(loc, i, scores[i].$2),
getResultValueText(loc, i, playerScores[i].$2),
],
),
],
);
}
/// Returns a list of player/team names and their corresponding scores, sorted by score according to the ruleset
List<(String, int)> getSortedScores() {
List<(String, int)> namedScores = [];
if (localMatch.isTeamMatch) {
final teams = localMatch.teams ?? [];
for (var team in teams) {
int score = team.score ?? 0;
namedScores.add((team.name, score));
}
final ruleset = localMatch.game.ruleset;
if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) {
namedScores.sort((a, b) => b.$2.compareTo(a.$2));
} else if (ruleset == Ruleset.lowestScore) {
namedScores.sort((a, b) => a.$2.compareTo(b.$2));
}
} else {
final scores = localMatch.scores;
for (var player in localMatch.players) {
int score = scores[player.id]?.score ?? 0;
namedScores.add((player.name, score));
}
final ruleset = localMatch.game.ruleset;
if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) {
namedScores.sort((a, b) => b.$2.compareTo(a.$2));
} else if (ruleset == Ruleset.lowestScore) {
namedScores.sort((a, b) => a.$2.compareTo(b.$2));
}
}
return namedScores;
}
/// Returns the text widget for the score or placement value, styled according to the ruleset
Widget getResultValueText(AppLocalizations loc, int index, int score) {
final ruleset = localMatch.game.ruleset;
final ruleset = match.game.ruleset;
if (ruleset == Ruleset.placement) {
return Text(
@@ -462,9 +410,9 @@ class _MatchDetailViewState extends State<MatchDetailView> {
// Returns if the result can be displayed in a single row
bool isSingleRowResult() {
return localMatch.game.ruleset == Ruleset.singleWinner ||
localMatch.game.ruleset == Ruleset.singleLoser ||
localMatch.game.ruleset == Ruleset.multipleWinners;
return match.game.ruleset == Ruleset.singleWinner ||
match.game.ruleset == Ruleset.singleLoser ||
match.game.ruleset == Ruleset.multipleWinners;
}
String getPlacementText(BuildContext context, int rank) {
@@ -495,19 +443,9 @@ class _MatchDetailViewState extends State<MatchDetailView> {
}
}
Future<void> updateScoresForCurrentMatch() async {
if (localMatch.isTeamMatch) {
final teams = await db.teamDao.getTeamsByMatchId(matchId: localMatch.id);
setState(() {
localMatch = localMatch.copyWith(teams: teams);
});
} else {
final scores = await db.scoreEntryDao.getAllMatchScores(
matchId: localMatch.id,
);
setState(() {
localMatch = localMatch.copyWith(scores: scores);
});
}
void updateScoresForCurrentMatch() {
db.scoreEntryDao
.getAllMatchScores(matchId: match.id)
.then((scores) => match.scores = scores);
}
}

View File

@@ -79,7 +79,7 @@ class _MatchViewState extends State<MatchView> {
visible: matches.isNotEmpty,
replacement: Center(
child: TopCenteredMessage(
icon: Icons.report,
icon: Icons.info,
title: loc.info,
message: loc.no_matches_created_yet,
),
@@ -97,6 +97,7 @@ class _MatchViewState extends State<MatchView> {
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: MatchTile(
onPlayerEdited: loadMatches,
width: MediaQuery.sizeOf(context).width * 0.95,
onTap: () async {
Navigator.push(

View File

@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/app_skeleton.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/colored_icon_container.dart';
import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart';
import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class PlayerDetailView extends StatefulWidget {
const PlayerDetailView({
super.key,
required this.player,
required this.callback,
});
/// The player to display
final Player player;
final VoidCallback callback;
@override
State<PlayerDetailView> createState() => _PlayerDetailViewState();
}
class _PlayerDetailViewState extends State<PlayerDetailView> {
late final AppDatabase db;
late Player _player;
late String playerNameCount;
bool isLoading = true;
/// Total matches played by this player
int totalMatches = 0;
/// Total matches won by this player
int matchesWon = 0;
/// Total groups this player belongs to
int totalGroups = 0;
/// Full list of groups this player belongs to
List<Group> playerGroups = List.filled(
4,
Group(name: 'Skeleton group', members: []),
);
/// Full list of matches this player played in
List<Match> playerMatches = List.filled(
4,
Match(
name: 'Skeleton match',
game: Game(name: 'Game name', ruleset: Ruleset.singleWinner),
players: [],
),
);
TextEditingController nameController = TextEditingController();
@override
void initState() {
super.initState();
_player = widget.player;
db = Provider.of<AppDatabase>(context, listen: false);
playerNameCount = getNameCountText(_player);
_loadData();
}
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(loc.player_profile),
actions: [
HapticIconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
showDialog<bool>(
context: context,
builder: (context) => CustomAlertDialog(
title: loc.delete_player,
content: Text(loc.this_cannot_be_undone),
actions: [
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(true),
text: loc.delete,
),
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(false),
buttonType: ButtonType.secondary,
text: loc.cancel,
),
],
),
).then((confirmed) async {
if (confirmed! && context.mounted) {
//TODO: implement player deletion in db
if (!context.mounted) return;
Navigator.pop(context);
widget.callback();
}
});
},
),
],
),
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.person,
containerSize: 55,
iconSize: 38,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_player.name,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: CustomTheme.textColor,
),
textAlign: TextAlign.center,
),
Text(
playerNameCount,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: CustomTheme.textColor.withAlpha(120),
),
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 5),
Text(
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(_player.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: CustomTheme.textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
InfoTile(
title: '${loc.matches_part_of} ($totalMatches)',
icon: Icons.sports_esports,
horizontalAlignment: CrossAxisAlignment.start,
content: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
alignment: Alignment.topLeft,
child: playerMatches.isNotEmpty
? Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: playerMatches.map((match) {
return TextIconTile(
text: match.name,
iconEnabled: false,
);
}).toList(),
)
: Text(
loc.no_matches_played_yet,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
),
),
const SizedBox(height: 15),
InfoTile(
title: '${loc.groups_part_of} ($totalGroups)',
icon: Icons.people,
horizontalAlignment: CrossAxisAlignment.start,
content: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
alignment: Alignment.topLeft,
child: playerGroups.isNotEmpty
? Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: playerGroups.map((group) {
return TextIconTile(
text: group.name,
iconEnabled: false,
);
}).toList(),
)
: Text(
loc.not_part_of_any_group,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
),
),
const SizedBox(height: 15),
InfoTile(
title: loc.statistics,
icon: Icons.bar_chart,
content: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
child: Column(
children: [
_buildStatRow(
loc.matches_played,
totalMatches.toString(),
),
_buildStatRow(loc.matches_won, matchesWon.toString()),
_buildStatRow(
loc.winrate,
'${totalMatches == 0 ? 0 : ((matchesWon / totalMatches) * 100).round()}%',
),
],
),
),
),
],
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom,
child: MainMenuButton(
text: loc.edit_player,
icon: Icons.edit,
onPressed: () async {
nameController.text = _player.name;
showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
return CustomAlertDialog(
title: loc.edit_name,
content: TextInputField(
controller: nameController,
hintText: loc.set_name,
onChanged: (_) => setDialogState(() {}),
),
actions: [
CustomDialogAction(
onPressed: isConfirmButtonEnabled()
? () => Navigator.of(context).pop(true)
: null,
text: loc.confirm,
),
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(false),
buttonType: ButtonType.secondary,
text: loc.cancel,
),
],
);
},
),
).then((confirmed) async {
if (confirmed! && context.mounted) {
final newName = nameController.text.trim();
if (newName != _player.name) {
final fetchedPlayerNameCount = await db.playerDao
.getNameCount(name: newName);
await db.playerDao.updatePlayerName(
playerId: _player.id,
name: newName,
);
widget.callback.call();
setState(() {
_player = Player(
name: newName,
createdAt: _player.createdAt,
id: _player.id,
nameCount: _player.nameCount,
description: _player.description,
);
// If there is already a player with the same name,
// the count of that player is 0, so we start counting from 2 to get the correct count for this player. If there are no players with the same name, we just show the name without a count.
playerNameCount = fetchedPlayerNameCount == 0
? ''
: ' #${fetchedPlayerNameCount + 1}';
});
}
}
});
},
),
),
],
),
),
);
}
/// Loads statistics for this player
Future<void> _loadData() async {
isLoading = true;
final fetchedMatches = await db.matchDao.getMatchesByPlayer(
playerId: _player.id,
);
final fetchedGroups = await db.groupDao.getGroupsByPlayer(
playerId: _player.id,
);
if (!mounted) return;
setState(() {
playerMatches = fetchedMatches;
totalMatches = fetchedMatches.length;
matchesWon = fetchedMatches
.where((match) => match.mvp.any((mvp) => mvp.id == _player.id))
.length;
playerGroups = fetchedGroups;
totalGroups = fetchedGroups.length;
isLoading = false;
});
}
/// Builds a single statistic row with a label and value
/// - [label]: The label of the statistic
/// - [value]: The value of the statistic
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 16,
color: CustomTheme.textColor,
),
),
],
),
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
);
}
bool isConfirmButtonEnabled() {
return nameController.text.trim().isNotEmpty;
}
}

View File

@@ -34,7 +34,6 @@ const allDependencies = <Package>[
_cli_util,
_clock,
_code_assets,
_code_builder,
_collection,
_convert,
_coverage,
@@ -154,6 +153,7 @@ const allDependencies = <Package>[
_source_map_stack_trace,
_source_maps,
_source_span,
_sqlcipher_flutter_libs,
_sqlite3,
_sqlite3_flutter_libs,
_sqlparser,
@@ -670,17 +670,17 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// build_runner 2.13.1
/// build_runner 2.15.0
const _build_runner = Package(
name: 'build_runner',
description: 'A build system for Dart code generation and modular compilation.',
repository: 'https://github.com/dart-lang/build/tree/master/build_runner',
authors: [],
version: '2.13.1',
version: '2.15.0',
spdxIdentifiers: ['BSD-3-Clause'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('analyzer'), PackageRef('args'), PackageRef('async'), PackageRef('build'), PackageRef('build_config'), PackageRef('build_daemon'), PackageRef('built_collection'), PackageRef('built_value'), PackageRef('code_builder'), PackageRef('collection'), PackageRef('convert'), PackageRef('crypto'), PackageRef('dart_style'), PackageRef('glob'), PackageRef('graphs'), PackageRef('http_multi_server'), PackageRef('io'), PackageRef('json_annotation'), PackageRef('logging'), PackageRef('meta'), PackageRef('mime'), PackageRef('package_config'), PackageRef('path'), PackageRef('pool'), PackageRef('pub_semver'), PackageRef('shelf'), PackageRef('shelf_web_socket'), PackageRef('stream_transform'), PackageRef('watcher'), PackageRef('web_socket_channel'), PackageRef('yaml')],
dependencies: [PackageRef('analyzer'), PackageRef('args'), PackageRef('async'), PackageRef('build'), PackageRef('build_config'), PackageRef('build_daemon'), PackageRef('built_collection'), PackageRef('built_value'), PackageRef('collection'), PackageRef('convert'), PackageRef('crypto'), PackageRef('dart_style'), PackageRef('glob'), PackageRef('graphs'), PackageRef('http_multi_server'), PackageRef('io'), PackageRef('json_annotation'), PackageRef('logging'), PackageRef('meta'), PackageRef('mime'), PackageRef('package_config'), PackageRef('path'), PackageRef('pool'), PackageRef('pub_semver'), PackageRef('shelf'), PackageRef('shelf_web_socket'), PackageRef('stream_transform'), PackageRef('watcher'), PackageRef('web_socket_channel'), PackageRef('yaml')],
devDependencies: [PackageRef('stream_channel'), PackageRef('test')],
license: '''Copyright 2016, the Dart project authors.
@@ -1510,47 +1510,6 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// code_builder 4.11.1
const _code_builder = Package(
name: 'code_builder',
description: 'A fluent, builder-based library for generating valid Dart code.',
repository: 'https://github.com/dart-lang/tools/tree/main/pkgs/code_builder',
authors: [],
version: '4.11.1',
spdxIdentifiers: ['BSD-3-Clause'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('built_collection'), PackageRef('built_value'), PackageRef('collection'), PackageRef('matcher'), PackageRef('meta')],
devDependencies: [PackageRef('build'), PackageRef('build_runner'), PackageRef('dart_style'), PackageRef('source_gen'), PackageRef('test')],
license: '''Copyright 2016, the Dart project authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// collection 1.19.1
const _collection = Package(
name: 'collection',
@@ -2581,14 +2540,14 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// drift 2.31.0
/// drift 2.33.0
const _drift = Package(
name: 'drift',
description: 'Drift is a reactive library to store relational data in Dart and Flutter applications.',
homepage: 'https://drift.simonbinder.eu/',
repository: 'https://github.com/simolus3/drift',
authors: [],
version: '2.31.0',
version: '2.33.0',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
@@ -2617,14 +2576,14 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// drift_dev 2.31.0
/// drift_dev 2.33.0
const _drift_dev = Package(
name: 'drift_dev',
description: 'Dev-dependency for users of drift. Contains the generator and development tools.',
homepage: 'https://drift.simonbinder.eu/',
repository: 'https://github.com/simolus3/drift',
authors: [],
version: '2.31.0',
version: '2.33.0',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
@@ -2653,18 +2612,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// drift_flutter 0.2.8
/// drift_flutter 0.3.0
const _drift_flutter = Package(
name: 'drift_flutter',
description: 'Easily set up drift databases across platforms in Flutter apps.',
homepage: 'https://drift.simonbinder.eu/',
repository: 'https://github.com/simolus3/drift',
authors: [],
version: '0.2.8',
version: '0.3.0',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('drift'), PackageRef('flutter'), PackageRef('meta'), PackageRef('path'), PackageRef('path_provider'), PackageRef('sqlite3'), PackageRef('sqlite3_flutter_libs')],
dependencies: [PackageRef('drift'), PackageRef('flutter'), PackageRef('meta'), PackageRef('path'), PackageRef('path_provider'), PackageRef('sqlite3'), PackageRef('sqlite3_flutter_libs'), PackageRef('sqlcipher_flutter_libs')],
devDependencies: [PackageRef('build_runner'), PackageRef('drift_dev'), PackageRef('lints'), PackageRef('test'), PackageRef('flutter_test'), PackageRef('async')],
license: '''MIT License
@@ -3057,18 +3016,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// file_saver 0.3.1
/// file_saver 0.4.0
const _file_saver = Package(
name: 'file_saver',
description: 'A Flutter plugin for saving files across all platforms (Android, iOS, Web, Windows, macOS, Linux). Save files from bytes, File objects, file paths, or download from URLs with a single method call. Features include MIME type support, Dio integration, and platform-specific save locations. Supports saveAs() dialog for user-selected locations on supported platforms.',
description: 'Save files from bytes, paths, streams, and URLs across Android, iOS, Web, Windows, macOS, and Linux.',
homepage: 'https://hassanansari.dev',
repository: 'https://github.com/incrediblezayed/file_saver',
authors: [],
version: '0.3.1',
version: '0.4.0',
spdxIdentifiers: ['BSD-3-Clause'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('collection'), PackageRef('dio'), PackageRef('flutter'), PackageRef('flutter_web_plugins'), PackageRef('path_provider'), PackageRef('path_provider_linux'), PackageRef('path_provider_windows'), PackageRef('web')],
dependencies: [PackageRef('collection'), PackageRef('dio'), PackageRef('flutter'), PackageRef('flutter_web_plugins'), PackageRef('path_provider'), PackageRef('web')],
devDependencies: [PackageRef('flutter_lints'), PackageRef('flutter_test')],
license: '''BSD 3-Clause License
@@ -37683,18 +37642,264 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// sqlite3 2.9.4
/// sqlcipher_flutter_libs 0.7.0+eol
const _sqlcipher_flutter_libs = Package(
name: 'sqlcipher_flutter_libs',
description: 'Not used anymore, update to version 3.x of package:sqlite3 instead',
homepage: 'https://github.com/simolus3/sqlite3.dart/tree/main/legacy/sqlcipher_flutter_libs',
authors: [],
version: '0.7.0+eol',
spdxIdentifiers: ['Pixar', 'MIT', 'BSD-3-Clause-HP'],
isMarkdown: false,
isSdk: false,
dependencies: [],
devDependencies: [],
license: '''sqlcipher_flutter_libs
MIT License
Copyright (c) 2020 Simon Binder
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
OpenSSL
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
--------------------------------------------------------------------------------
SQLCipher
Copyright (c) 2008-2020 Zetetic LLC
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the ZETETIC LLC nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// sqlite3 3.3.1
const _sqlite3 = Package(
name: 'sqlite3',
description: 'Provides lightweight yet convenient bindings to SQLite by using dart:ffi',
homepage: 'https://github.com/simolus3/sqlite3.dart/tree/main/sqlite3',
authors: [],
version: '2.9.4',
version: '3.3.1',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('collection'), PackageRef('ffi'), PackageRef('meta'), PackageRef('path'), PackageRef('web'), PackageRef('typed_data')],
devDependencies: [PackageRef('analyzer'), PackageRef('build_daemon'), PackageRef('build_runner'), PackageRef('dart_style'), PackageRef('http'), PackageRef('lints'), PackageRef('shelf'), PackageRef('shelf_static'), PackageRef('stream_channel'), PackageRef('test'), PackageRef('pub_semver'), PackageRef('convert')],
dependencies: [PackageRef('collection'), PackageRef('ffi'), PackageRef('meta'), PackageRef('path'), PackageRef('web'), PackageRef('typed_data'), PackageRef('hooks'), PackageRef('code_assets'), PackageRef('native_toolchain_c'), PackageRef('crypto')],
devDependencies: [PackageRef('analyzer'), PackageRef('build_daemon'), PackageRef('build_runner'), PackageRef('dart_style'), PackageRef('http'), PackageRef('lints'), PackageRef('shelf'), PackageRef('shelf_static'), PackageRef('stream_channel'), PackageRef('test'), PackageRef('pub_semver'), PackageRef('convert'), PackageRef('package_config'), PackageRef('logging')],
license: '''MIT License
Copyright (c) 2020 Simon Binder
@@ -37718,17 +37923,17 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// sqlite3_flutter_libs 0.5.42
/// sqlite3_flutter_libs 0.6.0+eol
const _sqlite3_flutter_libs = Package(
name: 'sqlite3_flutter_libs',
description: 'Flutter plugin to include native sqlite3 libraries with your app',
homepage: 'https://github.com/simolus3/sqlite3.dart/tree/v2/sqlite3_flutter_libs',
description: 'Not used anymore, update to version 3.x of package:sqlite3 instead',
homepage: 'https://github.com/simolus3/sqlite3.dart/tree/main/legacy/sqlite3_flutter_libs',
authors: [],
version: '0.5.42',
version: '0.6.0+eol',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('flutter')],
dependencies: [],
devDependencies: [],
license: '''MIT License
@@ -37753,14 +37958,14 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// sqlparser 0.43.1
/// sqlparser 0.44.4
const _sqlparser = Package(
name: 'sqlparser',
description: 'Parses sqlite statements and performs static analysis on them',
homepage: 'https://github.com/simolus3/drift/tree/develop/sqlparser',
repository: 'https://github.com/simolus3/drift',
authors: [],
version: '0.43.1',
version: '0.44.4',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
@@ -39415,12 +39620,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// tallee 0.0.33+273
/// tallee 0.0.35+277
const _tallee = Package(
name: 'tallee',
description: 'Tracking App for Card Games',
authors: [],
version: '0.0.33+273',
version: '0.0.35+277',
spdxIdentifiers: ['LGPL-3.0'],
isMarkdown: false,
isSdk: false,

View File

@@ -1,311 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/app_skeleton.dart';
import 'package:tallee/presentation/widgets/tiles/quick_info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/statistics_tile.dart';
import 'package:tallee/presentation/widgets/top_centered_message.dart';
class StatisticsView extends StatefulWidget {
/// A view that displays player statistics
const StatisticsView({super.key});
@override
State<StatisticsView> createState() => _StatisticsViewState();
}
class _StatisticsViewState extends State<StatisticsView> {
int matchCount = 0;
int groupCount = 0;
List<(Player, int)> winCounts = List.filled(6, (
Player(name: 'Skeleton Player'),
1,
));
List<(Player, int)> matchCounts = List.filled(6, (
Player(name: 'Skeleton Player'),
1,
));
List<(Player, double)> winRates = List.filled(6, (
Player(name: 'Skeleton Player'),
1,
));
bool isLoading = true;
@override
void initState() {
super.initState();
loadStatisticData();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return SingleChildScrollView(
child: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
QuickInfoTile(
width: constraints.maxWidth * 0.45,
height: constraints.maxHeight * 0.13,
title: loc.matches,
icon: Icons.groups_rounded,
value: matchCount,
),
SizedBox(width: constraints.maxWidth * 0.05),
QuickInfoTile(
width: constraints.maxWidth * 0.45,
height: constraints.maxHeight * 0.13,
title: loc.groups,
icon: Icons.groups_rounded,
value: groupCount,
),
],
),
SizedBox(height: constraints.maxHeight * 0.02),
Visibility(
visible:
winCounts.isEmpty &&
matchCounts.isEmpty &&
winRates.isEmpty,
replacement: Column(
children: [
StatisticsTile(
icon: Icons.sports_score,
title: loc.wins,
width: constraints.maxWidth * 0.95,
values: winCounts,
itemCount: 3,
barColor: Colors.green,
),
SizedBox(height: constraints.maxHeight * 0.02),
StatisticsTile(
icon: Icons.percent,
title: loc.winrate,
width: constraints.maxWidth * 0.95,
values: winRates,
itemCount: 5,
barColor: Colors.orange[700]!,
),
SizedBox(height: constraints.maxHeight * 0.02),
StatisticsTile(
icon: Icons.casino,
title: loc.amount_of_matches,
width: constraints.maxWidth * 0.95,
values: matchCounts,
itemCount: 10,
barColor: Colors.blue,
),
],
),
child: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: AppLocalizations.of(
context,
).no_statistics_available,
),
),
SizedBox(height: MediaQuery.paddingOf(context).bottom),
],
),
),
),
);
},
);
}
/// Loads matches and players from the database
/// and calculates statistics for each player
void loadStatisticData() {
final db = Provider.of<AppDatabase>(context, listen: false);
Future.wait([
db.matchDao.getAllMatches(),
db.playerDao.getAllPlayers(),
db.matchDao.getMatchCount(),
db.groupDao.getGroupCount(),
Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
]).then((results) async {
if (!mounted) return;
final matches = results[0] as List<Match>;
final players = results[1] as List<Player>;
matchCount = results[2] as int;
groupCount = results[3] as int;
winCounts = _calculateWinsForAllPlayers(
matches: matches,
players: players,
context: context,
);
matchCounts = _calculateMatchAmountsForAllPlayers(
matches: matches,
players: players,
context: context,
);
winRates = computeWinRatePercent(
winCounts: winCounts,
matchCounts: matchCounts,
);
setState(() {
isLoading = false;
});
});
}
/// Calculates the number of wins for each player
/// and returns a sorted list of tuples (playerName, winCount)
List<(Player, int)> _calculateWinsForAllPlayers({
required List<Match> matches,
required List<Player> players,
required BuildContext context,
}) {
List<(Player, int)> winCounts = [];
final loc = AppLocalizations.of(context);
// Getting the winners
for (var match in matches) {
final mvps = match.mvp;
for (var winner in mvps) {
final index = winCounts.indexWhere((entry) => entry.$1.id == winner.id);
// -1 means winner not found in winCounts
if (index != -1) {
final current = winCounts[index].$2;
winCounts[index] = (winner, current + 1);
} else {
winCounts.add((winner, 1));
}
}
}
// Adding all players with zero wins
for (var player in players) {
final index = winCounts.indexWhere((entry) => entry.$1.id == player.id);
// -1 means player not found in winCounts
if (index == -1) {
winCounts.add((player, 0));
}
}
// Replace player IDs with names
for (int i = 0; i < winCounts.length; i++) {
final playerId = winCounts[i].$1.id;
final player = players.firstWhere(
(p) => p.id == playerId,
orElse: () => Player(id: playerId, name: loc.not_available),
);
winCounts[i] = (player, winCounts[i].$2);
}
winCounts.sort((a, b) => b.$2.compareTo(a.$2));
return winCounts;
}
/// Calculates the number of matches played for each player
/// and returns a sorted list of tuples (playerName, matchCount)
List<(Player, int)> _calculateMatchAmountsForAllPlayers({
required List<Match> matches,
required List<Player> players,
required BuildContext context,
}) {
List<(Player, int)> matchCounts = [];
final loc = AppLocalizations.of(context);
// Counting matches for each player
for (var match in matches) {
for (Player player in match.players) {
// Check if the player is already in matchCounts
final index = matchCounts.indexWhere(
(entry) => entry.$1.id == player.id,
);
// -1 -> not found
if (index == -1) {
// Add new entry
matchCounts.add((player, 1));
} else {
// Update existing entry
final currentMatchAmount = matchCounts[index].$2;
matchCounts[index] = (player, currentMatchAmount + 1);
}
}
}
// Adding all players with zero matches
for (var player in players) {
final index = matchCounts.indexWhere((entry) => entry.$1.id == player.id);
// -1 means player not found in matchCounts
if (index == -1) {
matchCounts.add((player, 0));
}
}
// Replace player IDs with names
for (int i = 0; i < matchCounts.length; i++) {
final playerId = matchCounts[i].$1.id;
final player = players.firstWhere(
(p) => p.id == playerId,
orElse: () => Player(id: playerId, name: loc.not_available),
);
matchCounts[i] = (player, matchCounts[i].$2);
}
matchCounts.sort((a, b) => b.$2.compareTo(a.$2));
return matchCounts;
}
List<(Player, double)> computeWinRatePercent({
required List<(Player, int)> winCounts,
required List<(Player, int)> matchCounts,
}) {
final Map<Player, int> winsMap = {for (var e in winCounts) e.$1: e.$2};
final Map<Player, int> matchesMap = {for (var e in matchCounts) e.$1: e.$2};
// Get all unique player names
final player = {...matchesMap.keys};
// Calculate win rates
final result = player.map((name) {
final int w = winsMap[name] ?? 0;
final int m = matchesMap[name] ?? 0;
// Calculate percentage and round to 2 decimal places
// Avoid division by zero
final double percent = (m > 0)
? double.parse(((w / m)).toStringAsFixed(2))
: 0;
return (name, percent);
}).toList();
// Sort the result: first by winrate descending,
// then by wins descending in case of a tie
result.sort((a, b) {
final cmp = b.$2.compareTo(a.$2);
if (cmp != 0) return cmp;
final wa = winsMap[a.$1] ?? 0;
final wb = winsMap[b.$1] ?? 0;
return wb.compareTo(wa);
});
return result;
}
}

View File

@@ -0,0 +1,635 @@
import 'package:animated_custom_dropdown/custom_dropdown.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/statistic.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/buttons/animated_dialog_button.dart';
class CreateStatisticView extends StatefulWidget {
const CreateStatisticView({super.key, required this.onStatisticCreated});
final void Function() onStatisticCreated;
@override
State<CreateStatisticView> createState() => _CreateStatisticViewState();
}
class _CreateStatisticViewState extends State<CreateStatisticView> {
bool isLoading = false;
/* Data loaded from the database */
List<Player> players = [];
List<Game> games = [];
List<Group> groups = [];
/* User selections */
StatisticType? selectedType;
List<StatisticScope> selectedScope = [];
List<Game> selectedGames = [];
List<Player> selectedPlayers = [];
List<Group> selectedGroups = [];
Timeframe? selectedTimeframe;
@override
void initState() {
loadAllData();
super.initState();
}
@override
Widget build(BuildContext context) {
var loc = AppLocalizations.of(context);
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(title: Text(loc.create_statistic)),
body: Stack(
alignment: AlignmentDirectional.center,
children: [
SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 80,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Classifier title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.classifier,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Text(
'description',
textAlign: TextAlign.start,
style: TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
softWrap: true,
),
],
),
),
// Classifier selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<StatisticType>(
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
listItemBuilder:
(context, item, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
translateStatisticTypeToString(item, context),
style: itemStyle,
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerBuilder: (context, selectedType, enabled) => Text(
translateStatisticTypeToString(selectedType, context),
style: headerStyle,
),
hintText: loc.select_a_classifier,
items: StatisticType.values,
decoration: decoration,
onChanged: (value) {
setState(() {
selectedType = value;
});
},
),
),
const SizedBox(height: 10),
// Scope title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.scope,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Text(
'description',
textAlign: TextAlign.start,
style: TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// Scope selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<StatisticScope>.multiSelect(
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: loc.select_a_scope,
items: StatisticScope.values,
decoration: decoration,
listItemBuilder:
(context, scope, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
translateScopeToString(scope, context),
style: itemStyle,
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerListBuilder: (context, selectedItems, enabled) =>
Text(
selectedItems
.map((s) => translateScopeToString(s, context))
.join(', '),
style: headerStyle,
overflow: TextOverflow.ellipsis,
),
onListChanged: (List<StatisticScope> values) {
setState(() {
selectedScope = values;
});
},
),
),
if (selectedScope.contains(StatisticScope.selectedGames)) ...[
const SizedBox(height: 10),
// games title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.games,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
loc.select_the_filtered_games,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// game selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<Game>.multiSelect(
enabled: !isLoading,
disabledDecoration: disabledDecoration,
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: isLoading ? loc.loading : loc.select_a_game,
items: games,
decoration: decoration,
listItemBuilder:
(context, item, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Name
Text(item.name, style: itemStyle),
const SizedBox(width: 12),
// Ruleset
Text(
translateRulesetToString(
item.ruleset,
context,
),
style: hintStyle.copyWith(fontSize: 12),
),
],
),
// Check icon
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerListBuilder: (context, selectedItems, enabled) =>
Text(
selectedItems.map((g) => g.name).join(', '),
style: const TextStyle(
color: CustomTheme.textColor,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
onListChanged: (List<Game> values) {
setState(() {
selectedGames = values;
});
},
),
),
],
if (selectedScope.contains(
StatisticScope.selectedGroups,
)) ...[
const SizedBox(height: 10),
// groups title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.groups,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
loc.select_the_filtered_groups,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// groups selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<Group>.multiSelect(
enabled: !isLoading,
disabledDecoration: disabledDecoration,
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: isLoading ? loc.loading : loc.select_a_group,
items: groups,
decoration: decoration,
listItemBuilder:
(context, item, isSelected, onItemSelect) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name
Text(item.name, style: itemStyle),
const SizedBox(width: 12),
// Ruleset
Text(
' ${item.members.length.toString()} ${loc.members}',
style: hintStyle.copyWith(fontSize: 12),
),
],
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerListBuilder: (context, selectedItems, enabled) =>
Text(
selectedItems.map((g) => g.name).join(', '),
style: headerStyle,
overflow: TextOverflow.ellipsis,
),
onListChanged: (List<Group> groups) {
setState(() {
selectedGroups = groups;
});
},
),
),
],
if (selectedScope.contains(StatisticScope.timeframe)) ...[
const SizedBox(height: 10),
// timeframe title
Padding(
padding: const EdgeInsetsGeometry.only(left: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.timeframe,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
loc.select_the_filtered_timeframe,
textAlign: TextAlign.start,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 12,
),
),
],
),
),
// groups selection
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: CustomDropdown<Timeframe>(
enabled: !isLoading,
excludeSelected: false,
disabledDecoration: disabledDecoration,
closedHeaderPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
hintText: isLoading
? loc.loading
: loc.select_a_timeframe,
items: Timeframe.values,
decoration: decoration,
listItemBuilder:
(context, timeframe, isSelected, onItemSelect) =>
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
translateTimeframeToString(
timeframe,
context,
),
style: itemStyle,
),
if (isSelected)
const Icon(
Icons.check,
color: CustomTheme.textColor,
),
],
),
headerBuilder: (context, selectedTimeframe, enabled) =>
Text(
translateTimeframeToString(
selectedTimeframe,
context,
),
style: headerStyle,
overflow: TextOverflow.ellipsis,
),
onChanged: (Timeframe? timeframe) {
setState(() {
selectedTimeframe = timeframe;
});
},
),
),
],
],
),
),
// Create statistic button
Positioned(
bottom: MediaQuery.of(context).padding.bottom,
child: AnimatedDialogButton(
buttonConstraints: const BoxConstraints(minWidth: 350),
buttonText: loc.create_statistic,
onPressed: selectedType != null && selectedScope.isNotEmpty
? () => submitStatistic()
: null,
),
),
],
),
),
);
}
CustomDropdownDecoration get decoration => CustomDropdownDecoration(
listItemDecoration: const ListItemDecoration(
selectedIconBorder: BorderSide(color: CustomTheme.primaryColor, width: 1),
selectedIconColor: CustomTheme.primaryColor,
highlightColor: CustomTheme.secondaryColor,
splashColor: Colors.transparent,
selectedColor: CustomTheme.onBoxColor,
),
listItemStyle: itemStyle,
headerStyle: headerStyle,
hintStyle: hintStyle,
closedFillColor: CustomTheme.boxColor,
closedBorder: Border.all(color: CustomTheme.boxBorderColor, width: 1),
expandedFillColor: CustomTheme.boxColor,
expandedBorder: Border.all(color: CustomTheme.boxBorderColor, width: 1),
);
CustomDropdownDisabledDecoration get disabledDecoration =>
CustomDropdownDisabledDecoration(
fillColor: CustomTheme.boxColor.withAlpha(125),
border: Border.all(
color: CustomTheme.boxBorderColor.withAlpha(125),
width: 1,
),
headerStyle: disabledHeaderStyle,
hintStyle: disabledHintStyle,
);
TextStyle get headerStyle => const TextStyle(
color: CustomTheme.textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
);
TextStyle get itemStyle =>
const TextStyle(color: CustomTheme.textColor, fontSize: 14);
TextStyle get hintStyle =>
const TextStyle(color: CustomTheme.hintColor, fontSize: 14);
TextStyle get disabledHeaderStyle => const TextStyle(
color: CustomTheme.hintColor,
fontSize: 14,
fontWeight: FontWeight.bold,
);
TextStyle get disabledHintStyle =>
const TextStyle(color: CustomTheme.hintColor, fontSize: 14);
Future<void> loadAllData() async {
isLoading = true;
final db = Provider.of<AppDatabase>(context, listen: false);
Future.wait([
db.playerDao.getAllPlayers(),
db.groupDao.getAllGroups(),
db.gameDao.getAllGames(),
Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
])
.then((results) async {
players = results[0];
groups = results[1];
games = results[2];
isLoading = false;
})
.catchError((error) {
print('Error loading data: $error');
});
}
void submitStatistic() {
final newStatistic = Statistic(
type: selectedType!,
scopes: selectedScope,
timeframe: selectedTimeframe,
selectedGroups: selectedGroups,
selectedGames: selectedGames,
);
final db = Provider.of<AppDatabase>(context, listen: false);
db.statisticDao.addStatistic(statistic: newStatistic);
Navigator.of(context).pop(newStatistic);
}
}
String translateTimeframeToString(Timeframe timeframe, BuildContext context) {
final loc = AppLocalizations.of(context);
switch (timeframe) {
case Timeframe.last7Days:
return loc.last_7_days;
case Timeframe.last30Days:
return loc.last_30_days;
case Timeframe.last90Days:
return loc.last_90_days;
case Timeframe.last180Days:
return loc.last_180_days;
case Timeframe.lastYear:
return loc.last_year;
case Timeframe.allTime:
return loc.all_time;
}
}
String translateScopeToString(StatisticScope scope, BuildContext context) {
final loc = AppLocalizations.of(context);
switch (scope) {
case StatisticScope.allPlayers:
return loc.all_players;
case StatisticScope.selectedGroups:
return loc.selected_groups;
case StatisticScope.selectedGames:
return loc.selected_games;
case StatisticScope.timeframe:
return loc.timeframe;
}
}
String translateStatisticTypeToString(
StatisticType type,
BuildContext context,
) {
final loc = AppLocalizations.of(context);
switch (type) {
case StatisticType.totalMatches:
return loc.total_matches;
case StatisticType.totalWins:
return loc.total_wins;
case StatisticType.totalScore:
return loc.total_score;
case StatisticType.totalLosses:
return loc.total_losses;
case StatisticType.averageScore:
return loc.average_score;
case StatisticType.bestScore:
return loc.best_score;
case StatisticType.worstScore:
return loc.worst_score;
case StatisticType.winrate:
return loc.winrate;
}
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/statistic.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/statistics_view/create_statistic_view.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/statistics_tile.dart';
class StatisticDetailView extends StatefulWidget {
const StatisticDetailView({
super.key,
required this.statistic,
required this.values,
required this.icon,
required this.barColor,
});
final Statistic statistic;
final List<(Player, num)> values;
final IconData icon;
final Color barColor;
@override
State<StatisticDetailView> createState() => _StatisticDetailViewState();
}
class _StatisticDetailViewState extends State<StatisticDetailView> {
late int displayCount;
@override
void initState() {
super.initState();
displayCount = widget.statistic.displayCount;
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final title = translateStatisticTypeToString(
widget.statistic.type,
context,
);
const style = TextStyle(fontWeight: FontWeight.bold);
return Scaffold(
appBar: AppBar(
title: Text(title),
leading: HapticIconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () => handleBack(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
StatisticsTile(
icon: widget.icon,
title: title,
width: MediaQuery.sizeOf(context).width * 0.95,
values: widget.values,
barColor: widget.barColor,
selectedGroups: widget.statistic.selectedGroups,
selectedGames: widget.statistic.selectedGames,
displayCount: displayCount,
showAllValues: true,
),
const SizedBox(height: 12),
InfoTile(
icon: Icons.filter_alt,
title: loc.filter,
content: Column(
spacing: 12,
children: [
// Scopes
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.scope, style: style),
Text(
widget.statistic.scopes
.map(
(scope) => translateScopeToString(scope, context),
)
.join('\n'),
textAlign: TextAlign.end,
),
],
),
// Timeframe
if (widget.statistic.timeframe != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.timeframe, style: style),
Text(
translateTimeframeToString(
widget.statistic.timeframe!,
context,
),
textAlign: TextAlign.end,
),
],
),
// Groups
if (widget.statistic.selectedGroups != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.groups, style: style),
Text(
widget.statistic.selectedGroups!
.map((group) => group.name)
.join('\n'),
textAlign: TextAlign.end,
),
],
),
// Games
if (widget.statistic.selectedGames != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.games, style: style),
Text(
widget.statistic.selectedGames!
.map((game) => game.name)
.join('\n'),
textAlign: TextAlign.end,
),
],
),
if (widget.values.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(loc.displayed_entries, style: style),
Row(
children: [
HapticIconButton(
icon: const Icon(Icons.remove),
onPressed: displayCount <= 1
? null
: () => setState(() => displayCount -= 1),
),
SizedBox(
width: 30,
child: Text(
'$displayCount',
textAlign: TextAlign.center,
),
),
HapticIconButton(
icon: const Icon(Icons.add),
onPressed: displayCount >= widget.values.length
? null
: () => setState(() => displayCount += 1),
),
],
),
],
),
],
),
),
],
),
),
);
}
// Handles saving the display count and giving it to statistics view
Future<void> handleBack(BuildContext context) async {
final db = Provider.of<AppDatabase>(context, listen: false);
await db.statisticDao.updateDisplayCount(widget.statistic.id, displayCount);
if (context.mounted) Navigator.of(context).pop(displayCount);
}
}

View File

@@ -0,0 +1,322 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/statistic.dart';
import 'package:tallee/presentation/views/main_menu/statistics_view/create_statistic_view.dart'
show translateStatisticTypeToString;
import 'package:tallee/presentation/widgets/tiles/statistics_tile.dart';
List<Color> _colorPalette = AppColor.values
.map((c) => getColorFromAppColor(c))
.toList();
/// Returns the icon for the given statistic type.
IconData getStatisticIconForType(StatisticType type) =>
_getStatisticIcon(type: type);
/// Returns a color from the palette based on the statistic's ID.
Color getStatisticColorForStatistic(Statistic stat) => _getStatisticColor(stat);
/// Computes the statistic values for a given [Statistic].
List<(Player, num)> computeStatisticValues({
required Statistic statistic,
required List<Match> matches,
required List<Player> players,
}) {
final filteredMatches = _getFilterMatches(statistic, matches);
final filteredPlayers = _getFilteredPlayers(
statistic,
players,
filteredMatches,
);
return _computeValuesForType(
type: statistic.type,
matches: filteredMatches,
players: filteredPlayers,
);
}
/// Build the [StatisticsTile] for a given [Statistic].
Widget buildStatisticTile({
required Statistic statistic,
required List<Match> matches,
required List<Player> players,
required BuildContext context,
double? width,
}) {
final values = computeStatisticValues(
statistic: statistic,
matches: matches,
players: players,
);
return StatisticsTile(
icon: _getStatisticIcon(type: statistic.type),
title: translateStatisticTypeToString(statistic.type, context),
width: width ?? MediaQuery.sizeOf(context).width * 0.95,
values: values,
barColor: _getStatisticColor(statistic),
displayCount: statistic.displayCount,
selectedGroups: statistic.selectedGroups,
selectedGames: statistic.selectedGames,
);
}
List<Match> _getFilterMatches(Statistic statistic, List<Match> matches) {
List<Match> filteredMatches = matches;
// Filter timeframe
if (statistic.scopes.contains(StatisticScope.timeframe) &&
statistic.timeframe != null) {
final minDate = _getMinimumDate(timeframe: statistic.timeframe!);
print(
'Filtering matches by timeframe: ${statistic.timeframe}, minDate: $minDate',
);
if (minDate != null) {
filteredMatches = matches
.where((m) => m.endedAt != null && m.endedAt!.isAfter(minDate))
.toList();
}
}
// Filter games
if (statistic.scopes.contains(StatisticScope.selectedGames) &&
(statistic.selectedGames?.isNotEmpty ?? false)) {
final gameIds = statistic.selectedGames!.map((g) => g.id).toSet();
filteredMatches = filteredMatches
.where((match) => gameIds.contains(match.game.id))
.toList();
}
// Filter groups
if (statistic.scopes.contains(StatisticScope.selectedGroups) &&
(statistic.selectedGroups?.isNotEmpty ?? false)) {
final groupIds = statistic.selectedGroups!.map((g) => g.id).toSet();
filteredMatches = filteredMatches
.where((m) => m.group != null && groupIds.contains(m.group!.id))
.toList();
}
return filteredMatches;
}
/// Returns a [Player] List with the selected players depending on
List<Player> _getFilteredPlayers(
Statistic statistic,
List<Player> allPlayers,
List<Match> filteredMatches,
) {
// allPlayers
if (statistic.scopes.contains(StatisticScope.allPlayers)) {
return allPlayers;
}
// selectedGroups -> only members
if (statistic.scopes.contains(StatisticScope.selectedGroups) &&
(statistic.selectedGroups?.isNotEmpty ?? false)) {
final Set<String> ids = {
for (final g in statistic.selectedGroups!)
for (final p in g.members) p.id,
};
return allPlayers.where((p) => ids.contains(p.id)).toList();
}
// Else -> all players from filtered matches
final Set<String> ids = {
for (final m in filteredMatches)
for (final p in m.players) p.id,
};
return allPlayers.where((p) => ids.contains(p.id)).toList();
}
/// Returns a [DateTime] with the minimum time and date the [timeframe] allows
DateTime? _getMinimumDate({required Timeframe timeframe}) {
final now = DateTime.now();
switch (timeframe) {
case Timeframe.last7Days:
return now.subtract(const Duration(days: 7));
case Timeframe.last30Days:
return now.subtract(const Duration(days: 30));
case Timeframe.last90Days:
return now.subtract(const Duration(days: 90));
case Timeframe.last180Days:
return now.subtract(const Duration(days: 180));
case Timeframe.lastYear:
return now.subtract(const Duration(days: 365));
case Timeframe.allTime:
return null;
}
}
/// Computes the statistic values for each player based on the statistic type
/// and returns a list of (Player, value) tuples sorted descending by value.
List<(Player, num)> _computeValuesForType({
required StatisticType type,
required List<Match> matches,
required List<Player> players,
}) {
switch (type) {
case StatisticType.totalMatches:
return _sortDesc(
players.map((p) => (p, _matchesPlayed(p, matches) as num)).toList(),
);
case StatisticType.totalWins:
return _sortDesc(
players.map((p) => (p, _wins(p, matches) as num)).toList(),
);
case StatisticType.totalLosses:
return _sortDesc(
players
.map(
(p) =>
(p, (_matchesPlayed(p, matches) - _wins(p, matches)) as num),
)
.toList(),
);
case StatisticType.totalScore:
return _sortDesc(
players.map((p) => (p, _totalScore(p, matches) as num)).toList(),
);
case StatisticType.averageScore:
return _sortDesc(
players.map((p) {
final scores = _scoresOf(p, matches);
final avg = scores.isEmpty
? 0.0
: double.parse(
(scores.reduce((a, b) => a + b) / scores.length)
.toStringAsFixed(2),
);
return (p, avg as num);
}).toList(),
);
case StatisticType.bestScore:
return _sortDesc(
players.map((p) {
final scores = _scoresOf(p, matches);
final best = scores.isEmpty ? 0 : scores.reduce(max);
return (p, best as num);
}).toList(),
);
case StatisticType.worstScore:
// Ascending here is more meaningful for "worst", but keep the
// existing tile semantics (largest bar = top entry) by sorting
// descending on the inverse — i.e. show smallest score on top.
final entries = players.map((p) {
final scores = _scoresOf(p, matches);
final worst = scores.isEmpty ? 0 : scores.reduce(min);
return (p, worst as num);
}).toList();
entries.sort((a, b) => a.$2.compareTo(b.$2));
return entries;
case StatisticType.winrate:
return _sortDesc(
players.map((p) {
final played = _matchesPlayed(p, matches);
final wins = _wins(p, matches);
final rate = played == 0
? 0.0
: double.parse((wins / played).toStringAsFixed(2));
return (p, rate as num);
}).toList(),
);
}
}
/* Helper functions for different statistic types */
/// Detemerines how many matches the player has played in the given list of matches.
int _matchesPlayed(Player p, List<Match> matches) =>
matches.where((m) => m.players.any((mp) => mp.id == p.id)).length;
/// Determines how many matches the player has won in the given list of matches.
int _wins(Player p, List<Match> matches) =>
matches.where((m) => m.mvp.any((mp) => mp.id == p.id)).length;
/// Determines the total score of the player in the given list of matches.
int _totalScore(Player p, List<Match> matches) {
var total = 0;
for (final m in matches) {
final s = m.scores[p.id];
if (s != null) total += s.score;
}
return total;
}
/// Returns a list of all scores the player has achieved in the given list of matches.
List<int> _scoresOf(Player p, List<Match> matches) => [
for (final m in matches)
if (m.scores[p.id] != null) m.scores[p.id]!.score,
];
/// Returns the list of entries sorted descending by the statistic value.
List<(Player, num)> _sortDesc(List<(Player, num)> entries) {
entries.sort((a, b) => b.$2.compareTo(a.$2));
return entries;
}
/* Icon and color */
/// Returns the icon for the given statistic type.
IconData _getStatisticIcon({required StatisticType type}) {
switch (type) {
case StatisticType.totalMatches:
return Icons.casino;
case StatisticType.totalWins:
return Icons.emoji_events;
case StatisticType.totalLosses:
return Icons.sentiment_dissatisfied;
case StatisticType.totalScore:
return Icons.scoreboard;
case StatisticType.averageScore:
return Icons.show_chart;
case StatisticType.bestScore:
return Icons.trending_up;
case StatisticType.worstScore:
return Icons.trending_down;
case StatisticType.winrate:
return Icons.percent;
}
}
/// Returns a color from the palette based on the statistic's ID as random seed.
Color _getStatisticColor(Statistic stat) {
final seed = stat.id.hashCode;
return _colorPalette[seed.abs() % _colorPalette.length];
}
/* Skeleton data */
/// A placeholder tile with mock data for the loading state.
Widget buildSkeletonStatisticTile({required BuildContext context}) {
final count = 4 + Random().nextInt(5); // 4..8
final values = <(Player, num)>[
for (var i = 0; i < count; i++)
(Player(name: 'Player ${i + 1}'), count - i),
];
return StatisticsTile(
icon: Icons.bar_chart,
title: 'Skeleton title',
width: MediaQuery.sizeOf(context).width * 0.95,
values: values,
barColor: _colorPalette[Random().nextInt(_colorPalette.length)],
selectedGames: [Game(name: 'Game 1', ruleset: Ruleset.highestScore)],
selectedGroups: [Group(name: 'Group 1', members: [])],
displayCount: 5,
);
}

View File

@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/statistic.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/statistics_view/create_statistic_view.dart';
import 'package:tallee/presentation/views/main_menu/statistics_view/statistic_detail_view.dart';
import 'package:tallee/presentation/views/main_menu/statistics_view/statistic_tile_factory.dart';
import 'package:tallee/presentation/widgets/app_skeleton.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/top_centered_message.dart';
class StatisticsView extends StatefulWidget {
/// A view that displays player statistics
const StatisticsView({super.key});
@override
State<StatisticsView> createState() => _StatisticsViewState();
}
class _StatisticsViewState extends State<StatisticsView> {
bool isLoading = true;
List<Match> _allMatches = const [];
List<Player> _allPlayers = const [];
List<Statistic> _statistics = const [];
List<Widget> statisticTiles = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
loadStatistics(context);
});
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Stack(
alignment: AlignmentDirectional.bottomCenter,
fit: StackFit.expand,
children: [
Visibility(
visible: statisticTiles.isNotEmpty,
replacement: Center(
child: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: loc.no_statistics_created_yet,
),
),
child: SingleChildScrollView(
child: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...statisticTiles,
SizedBox(
height: MediaQuery.paddingOf(context).bottom + 80,
),
],
),
),
),
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 20,
child: MainMenuButton(
text: loc.create_statistic,
icon: Icons.bar_chart,
onPressed: () async {
Statistic newStatistic = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateStatisticView(
onStatisticCreated: () => loadStatistics(context),
),
),
);
if (!context.mounted) return;
setState(() {
_statistics = [..._statistics, newStatistic];
statisticTiles = _statistics
.map((stat) => _buildStatisticTile(context, stat))
.toList();
});
},
),
),
],
);
},
);
}
Future<void> loadStatistics(BuildContext context) async {
setState(() {
isLoading = true;
statisticTiles = List.generate(
4,
(index) => Column(
children: [
buildSkeletonStatisticTile(context: context),
const SizedBox(height: 12),
],
),
);
});
final db = Provider.of<AppDatabase>(context, listen: false);
final results = await Future.wait([
db.statisticDao.getAllStatistics(),
db.matchDao.getAllMatches(),
db.playerDao.getAllPlayers(),
Future.delayed(Constants.MINIMUM_SKELETON_DURATION),
]);
if (!mounted) return;
final statistics = results[0] as List<Statistic>;
_allMatches = results[1] as List<Match>;
_allPlayers = results[2] as List<Player>;
_statistics = statistics;
setState(() {
statisticTiles = _statistics
.map((stat) => _buildStatisticTile(context, stat))
.toList();
isLoading = false;
});
}
Widget _buildStatisticTile(BuildContext context, Statistic statistic) {
final values = computeStatisticValues(
statistic: statistic,
matches: _allMatches,
players: _allPlayers,
);
return GestureDetector(
onTap: () async {
final newDisplayCount = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => StatisticDetailView(
statistic: statistic,
values: values,
icon: getStatisticIconForType(statistic.type),
barColor: getStatisticColorForStatistic(statistic),
),
),
);
if (newDisplayCount != null &&
newDisplayCount != statistic.displayCount) {
setState(() {
_statistics = _statistics
.map(
(stat) => stat.id == statistic.id
? stat.copyWith(displayCount: newDisplayCount)
: stat,
)
.toList();
statisticTiles = _statistics
.map((stat) => _buildStatisticTile(context, stat))
.toList();
});
}
},
child: buildStatisticTile(
statistic: statistic,
matches: _allMatches,
players: _allPlayers,
context: context,
),
);
}
}

View File

@@ -6,11 +6,13 @@ class AppSkeleton extends StatefulWidget {
/// - [child]: The widget tree to apply the skeleton effect to.
/// - [enabled]: A boolean to enable or disable the skeleton effect.
/// - [fixLayoutBuilder]: A boolean to fix the layout builder for AnimatedSwitcher.
/// - [alignment]: The alignment used for the custom layout builder and optional Align wrapper. Defaults to [Alignment.center].
const AppSkeleton({
super.key,
required this.child,
this.enabled = true,
this.fixLayoutBuilder = false,
this.alignment = Alignment.center,
});
/// The widget tree to apply the skeleton effect to.
@@ -22,6 +24,9 @@ class AppSkeleton extends StatefulWidget {
/// A boolean to fix the layout builder for AnimatedSwitcher.
final bool fixLayoutBuilder;
/// The alignment used for the custom layout builder and optional Align wrapper
final Alignment alignment;
@override
State<AppSkeleton> createState() => _AppSkeletonState();
}
@@ -45,13 +50,14 @@ class _AppSkeletonState extends State<AppSkeleton> {
layoutBuilder: !widget.fixLayoutBuilder
? AnimatedSwitcher.defaultLayoutBuilder
: (Widget? currentChild, List<Widget> previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [...previousChildren, ?currentChild],
);
final children = <Widget>[...previousChildren];
if (currentChild != null) children.add(currentChild);
return Stack(alignment: widget.alignment, children: children);
},
),
child: widget.child,
child: widget.fixLayoutBuilder
? Align(alignment: widget.alignment, child: widget.child)
: widget.child,
);
}
}

View File

@@ -11,11 +11,10 @@ class AnimatedDialogButton extends StatefulWidget {
const AnimatedDialogButton({
super.key,
required this.buttonText,
required this.onPressed,
this.onPressed,
this.buttonConstraints,
this.buttonType = ButtonType.primary,
this.isDescructive = false,
this.content,
});
final String buttonText;
@@ -28,8 +27,6 @@ class AnimatedDialogButton extends StatefulWidget {
final bool isDescructive;
final Widget? content;
@override
State<AnimatedDialogButton> createState() => _AnimatedDialogButtonState();
}
@@ -41,12 +38,12 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
Widget build(BuildContext context) {
final textStyling = _getTextStyling();
final buttonDecoration = _getButtonDecoration();
final isDisabled = widget.onPressed == null;
bool isDisabled = widget.onPressed == null;
return IgnorePointer(
ignoring: isDisabled,
child: Opacity(
opacity: isDisabled ? 0.4 : 1.0,
opacity: isDisabled ? 0.5 : 1.0,
child: GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
@@ -67,13 +64,11 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
vertical: 12,
),
margin: const EdgeInsets.symmetric(vertical: 8),
child: widget.buttonText == ''
? widget.content!
: Text(
widget.buttonText,
style: textStyling,
textAlign: TextAlign.center,
),
child: Text(
widget.buttonText,
style: textStyling,
textAlign: TextAlign.center,
),
),
),
),

View File

@@ -56,7 +56,6 @@ class CustomWidthButton extends StatelessWidget {
onPressed!.call();
},
style: ElevatedButton.styleFrom(
splashFactory: NoSplash.splashFactory,
foregroundColor: textcolor,
disabledForegroundColor: disabledTextColor,
backgroundColor: buttonBackgroundColor,
@@ -92,7 +91,6 @@ class CustomWidthButton extends StatelessWidget {
onPressed!.call();
},
style: OutlinedButton.styleFrom(
splashFactory: NoSplash.splashFactory,
foregroundColor: textcolor,
disabledForegroundColor: disabledTextColor,
backgroundColor: buttonBackgroundColor,
@@ -130,7 +128,6 @@ class CustomWidthButton extends StatelessWidget {
onPressed!.call();
},
style: TextButton.styleFrom(
splashFactory: NoSplash.splashFactory,
foregroundColor: textcolor,
disabledForegroundColor: disabledTextColor,
backgroundColor: buttonBackgroundColor,

View File

@@ -17,7 +17,7 @@ class MainMenuButton extends StatefulWidget {
});
/// The callback to be invoked when the button is pressed.
final void Function()? onPressed;
final void Function() onPressed;
/// The icon of the button.
final IconData icon;
@@ -32,11 +32,9 @@ class MainMenuButton extends StatefulWidget {
}
class _MainMenuButtonState extends State<MainMenuButton>
with TickerProviderStateMixin {
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late AnimationController _disabledAnimationController;
late Animation<double> _scaleAnimation;
late Animation<double> _disabledScaleAnimation;
/// How long the button needs to be pressed to register it as long press
Timer? _longPressTimer;
@@ -55,67 +53,45 @@ class _MainMenuButtonState extends State<MainMenuButton>
vsync: this,
);
_disabledAnimationController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_disabledScaleAnimation = Tween<double>(begin: 1.0, end: 0.98).animate(
CurvedAnimation(
parent: _disabledAnimationController,
curve: Curves.easeInOut,
),
);
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: widget.onPressed == null
? _disabledScaleAnimation
: _scaleAnimation,
scale: _scaleAnimation,
child: GestureDetector(
onTapDown: (_) {
if (widget.onPressed == null) {
_disabledAnimationController.forward();
} else {
_animationController.forward();
if (widget.onLongPressed != null) {
_longPressTimer = Timer(
const Duration(milliseconds: 400),
() async {
_isLongPressing = true;
widget.onLongPressed?.call();
await HapticFeedback.heavyImpact();
_repeatTimer = Timer.periodic(
const Duration(milliseconds: 250),
(_) async {
widget.onLongPressed?.call();
await HapticFeedback.heavyImpact();
},
);
},
);
}
_animationController.forward();
if (widget.onLongPressed != null) {
_longPressTimer = Timer(
const Duration(milliseconds: 400),
() async {
_isLongPressing = true;
widget.onLongPressed?.call();
await HapticFeedback.heavyImpact();
_repeatTimer = Timer.periodic(
const Duration(milliseconds: 250),
(_) async {
widget.onLongPressed?.call();
await HapticFeedback.heavyImpact();
},
);
},
);
}
},
onTapUp: (_) async {
if (widget.onPressed == null) {
_disabledAnimationController.reverse();
} else {
_cancelTimers();
if (mounted && !_isLongPressing) {
await HapticFeedback.selectionClick();
widget.onPressed?.call();
}
_isLongPressing = false;
await Future.delayed(const Duration(milliseconds: 100));
await _animationController.reverse();
_cancelTimers();
if (mounted && !_isLongPressing) {
await HapticFeedback.selectionClick();
widget.onPressed();
}
_isLongPressing = false;
await Future.delayed(const Duration(milliseconds: 100));
await _animationController.reverse();
},
onTapCancel: () {
_isLongPressing = false;
@@ -124,7 +100,7 @@ class _MainMenuButtonState extends State<MainMenuButton>
},
child: Container(
decoration: BoxDecoration(
color: widget.onPressed == null ? Colors.grey : Colors.white,
color: Colors.white,
borderRadius: BorderRadius.circular(30),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
@@ -155,7 +131,6 @@ class _MainMenuButtonState extends State<MainMenuButton>
void dispose() {
_cancelTimers();
_animationController.dispose();
_disabledAnimationController.dispose();
super.dispose();
}

View File

@@ -1,103 +0,0 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/models/team.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class TeamCard extends StatelessWidget {
const TeamCard({
super.key,
required this.team,
this.compact = false,
this.width = double.infinity,
});
final Team team;
final bool compact;
final double width;
@override
Widget build(BuildContext context) {
final teamColor = getColorFromGameColor(team.color);
if (compact) {
return Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: teamColor.withAlpha(50),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: teamColor, width: 2),
),
child: Row(
children: [
Expanded(
child: Text(
team.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 1,
height: 14,
color: Colors.white.withValues(alpha: 0.35),
),
const SizedBox(width: 8),
const Icon(Icons.people_alt_rounded, size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(
'${team.members.length}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
);
} else {
return Container(
width: width,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
decoration: BoxDecoration(
color: teamColor.withAlpha(50),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: teamColor, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text(
team.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: CustomTheme.textColor,
),
),
Wrap(
spacing: 6,
runSpacing: 6,
children: team.members.map((player) {
return TextIconTile(
text: player.name,
suffixText: getNameCountText(player),
);
}).toList(),
),
],
),
);
}
}
}

View File

@@ -19,7 +19,6 @@ class CustomAlertDialog extends StatelessWidget {
final String title;
final Widget content;
final List<CustomDialogAction> actions;
@override
Widget build(BuildContext context) {
return AlertDialog(

View File

@@ -10,7 +10,7 @@ class CustomDialogAction extends StatelessWidget {
/// - [onPressed]: Callback function that is triggered when the button is pressed.
const CustomDialogAction({
super.key,
required this.onPressed,
this.onPressed,
required this.text,
this.buttonType = ButtonType.primary,
this.isDestructive = false,
@@ -20,17 +20,18 @@ class CustomDialogAction extends StatelessWidget {
final ButtonType buttonType;
final VoidCallback onPressed;
final VoidCallback? onPressed;
final bool isDestructive;
@override
Widget build(BuildContext context) {
return AnimatedDialogButton(
onPressed: () async {
await HapticFeedback.selectionClick();
onPressed.call();
},
onPressed: onPressed != null
? () async {
await HapticFeedback.selectionClick();
onPressed?.call();
}
: null,
buttonText: text,
buttonType: buttonType,
isDescructive: isDestructive,

View File

@@ -16,37 +16,40 @@ class GameLabel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final backgroundColor = getColorFromGameColor(color);
final backgroundColor = getColorFromAppColor(color);
final fontColor = backgroundColor.computeLuminance() > 0.5
? Colors.black
: Colors.white;
return IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Title
Container(
decoration: BoxDecoration(
color: backgroundColor.withAlpha(230),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
title,
style: TextStyle(
fontSize: 12,
color: fontColor,
fontWeight: FontWeight.bold,
),
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Title
Container(
decoration: BoxDecoration(
color: backgroundColor.withAlpha(230),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontSize: 12,
color: fontColor,
fontWeight: FontWeight.bold,
),
),
),
// Description
Container(
// Description
Flexible(
child: Container(
decoration: BoxDecoration(
color: backgroundColor.withAlpha(140),
borderRadius: const BorderRadius.only(
@@ -57,6 +60,9 @@ class GameLabel extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontSize: 12,
color: fontColor,
@@ -64,8 +70,8 @@ class GameLabel extends StatelessWidget {
),
),
),
],
),
),
],
);
}
}

View File

@@ -26,6 +26,7 @@ class PlayerSelection extends StatefulWidget {
this.availablePlayers,
this.initialSelectedPlayers,
required this.onChanged,
this.onPlayerCreated,
});
/// An optional list of players to choose from. If null, all players from the database are used.
@@ -37,6 +38,9 @@ class PlayerSelection extends StatefulWidget {
/// A callback function that is invoked whenever the selection changes,
final Function(List<Player> value) onChanged;
/// A callback function that is invoked when a player was created in this widget
final VoidCallback? onPlayerCreated;
@override
State<PlayerSelection> createState() => _PlayerSelectionState();
}
@@ -143,9 +147,9 @@ class _PlayerSelectionState extends State<PlayerSelection> {
child: TextIconTile(
text: player.name,
suffixText: getNameCountText(player),
onIconTap: () async {
await HapticFeedback.selectionClick();
setState(() {
onIconTap: () {
setState(() async {
await HapticFeedback.selectionClick();
// Removes the player from the selection and notifies the parent.
selectedPlayers.remove(player);
widget.onChanged([...selectedPlayers]);
@@ -252,9 +256,6 @@ class _PlayerSelectionState extends State<PlayerSelection> {
),
)
.toList();
suggestedPlayers = suggestedPlayers
.where((p) => !selectedPlayers.any((sp) => sp.id == p.id))
.toList();
}
} else {
// Otherwise, use the loaded players from the database.
@@ -326,6 +327,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
/// Updates the state after successfully adding a new player.
void _handleSuccessfulPlayerCreation(Player player) {
widget.onPlayerCreated?.call();
selectedPlayers.insert(0, player);
widget.onChanged([...selectedPlayers]);
allPlayers.add(player);

View File

@@ -57,6 +57,7 @@ class TextInputField extends StatelessWidget {
filled: true,
fillColor: CustomTheme.boxColor,
hintText: hintText,
hintStyle: const TextStyle(fontSize: 18),
counterText: showCounterText ? null : '',
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),

View File

@@ -7,7 +7,6 @@ import 'package:tallee/core/enums.dart';
class GameTile extends StatelessWidget {
/// A list tile widget that displays a title and description, with optional highlighting and badge.
/// - [title]: The title text displayed on the tile.
/// - [subtitle]: An optional subtitle displayed under the title.
/// - [description]: The description text displayed below the title.
/// - [onTap]: The callback invoked when the tile is tapped.
/// - [onLongPress]: The callback invoked when the tile is tapped.
@@ -18,7 +17,6 @@ class GameTile extends StatelessWidget {
super.key,
required this.title,
required this.description,
this.subtitle,
this.onTap,
this.onLongPress,
this.isHighlighted = false,
@@ -26,20 +24,25 @@ class GameTile extends StatelessWidget {
this.badgeColor,
});
/// The title text displayed on the tile.
final String title;
final String? subtitle;
/// The description text displayed below the title.
final String description;
/// The callback invoked when the tile is tapped.
final VoidCallback? onTap;
/// The callback invoked when the tile is long-pressed.
final VoidCallback? onLongPress;
/// A boolean to determine if the tile should be highlighted.
final bool isHighlighted;
/// Optional text to display in a badge on the right side of the title.
final String? badgeText;
/// Optional color for the badge background.
final Color? badgeColor;
@override
@@ -48,7 +51,7 @@ class GameTile extends StatelessWidget {
? (badgeColor!.computeLuminance() > 0.5 ? Colors.black : Colors.white)
: Colors.white;
final gameColor = badgeColor ?? getColorFromGameColor(AppColor.orange);
final gameColor = badgeColor ?? getColorFromAppColor(AppColor.orange);
return GestureDetector(
onTap: () async {
@@ -64,14 +67,13 @@ class GameTile extends StatelessWidget {
}
},
child: AnimatedContainer(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
decoration: !isHighlighted
? CustomTheme.standardBoxDecoration
: CustomTheme.highlightedBoxDecoration.copyWith(
border: Border.all(
color: gameColor.withValues(alpha: 0.9),
width: 2,
strokeAlign: BorderSide.strokeAlignCenter,
),
),
duration: const Duration(milliseconds: 200),
@@ -116,21 +118,6 @@ class GameTile extends StatelessWidget {
),
),
// Title
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.hintColor,
),
),
],
// Badge
if (badgeText != null) ...[
const SizedBox(height: 5),

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/presentation/views/main_menu/player_detail_view.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class GroupTile extends StatefulWidget {
@@ -15,6 +17,7 @@ class GroupTile extends StatefulWidget {
required this.group,
this.isHighlighted = false,
this.onTap,
this.onPlayerChanged,
});
/// The group data to be displayed.
@@ -26,6 +29,9 @@ class GroupTile extends StatefulWidget {
/// Callback function to be executed when the tile is tapped.
final VoidCallback? onTap;
/// Callback function to be executed when the players in the group are changed.
final VoidCallback? onPlayerChanged;
@override
State<GroupTile> createState() => _GroupTileState();
}
@@ -91,6 +97,20 @@ class _GroupTileState extends State<GroupTile> {
TextIconTile(
text: member.name,
suffixText: getNameCountText(member),
iconEnabled: false,
onTileTap: () {
Navigator.push(
context,
adaptivePageRoute(
builder: (context) => PlayerDetailView(
player: member,
callback: () {
widget.onPlayerChanged?.call();
},
),
),
);
},
),
],
),

View File

@@ -5,12 +5,12 @@ import 'package:tallee/core/custom_theme.dart';
class CustomCheckboxListTile extends StatelessWidget {
const CustomCheckboxListTile({
super.key,
required this.content,
required this.text,
required this.value,
required this.onChanged,
});
final Widget content;
final String text;
final bool value;
final ValueChanged<bool> onChanged;
@@ -39,7 +39,16 @@ class CustomCheckboxListTile extends StatelessWidget {
onChanged(v);
},
),
Expanded(child: content),
Expanded(
child: Text(
text,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
),

View File

@@ -8,13 +8,13 @@ class CustomRadioListTile<T> extends StatelessWidget {
/// - [onContainerTap]: The callback invoked when the container is tapped.
const CustomRadioListTile({
super.key,
required this.content,
required this.text,
required this.value,
required this.onContainerTap,
});
/// The text to display next to the radio button.
final Widget content;
final String text;
/// The value associated with the radio button.
final T value;
@@ -37,7 +37,16 @@ class CustomRadioListTile<T> extends StatelessWidget {
child: Row(
children: [
Radio<T>(value: value, toggleable: true),
Expanded(child: content),
Expanded(
child: Text(
text,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
),

View File

@@ -5,34 +5,36 @@ import 'package:tallee/l10n/generated/app_localizations.dart';
class ScoreListTile extends StatelessWidget {
/// A custom list tile widget that has a text field for inputting a score.
/// - [content]: The leading Widget to be displayed.
/// - [text]: The leading text to be displayed.
/// - [controller]: The controller for the text field to input the score.
const ScoreListTile({
super.key,
required this.content,
required this.text,
required this.controller,
this.horizontalPadding = 20,
});
final Widget content;
/// The text to display next to the radio button.
final String text;
final TextEditingController controller;
final double horizontalPadding;
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: const BoxDecoration(color: CustomTheme.boxColor),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
content,
Text(
text,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500),
),
SizedBox(
width: 100,
height: 40,

View File

@@ -3,12 +3,13 @@ import 'dart:core' hide Match;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/cards/team_card.dart';
import 'package:tallee/presentation/views/main_menu/player_detail_view.dart';
import 'package:tallee/presentation/widgets/game_label.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -18,11 +19,14 @@ class MatchTile extends StatefulWidget {
/// - [match]: The match data to be displayed.
/// - [onTap]: The callback invoked when the tile is tapped.
/// - [width]: Optional width for the tile.
/// - [compact]: Whether to display the tile in a compact mode
const MatchTile({
super.key,
required this.match,
required this.onTap,
this.width,
this.compact = false,
this.onPlayerEdited,
});
/// The match data to be displayed.
@@ -31,9 +35,15 @@ class MatchTile extends StatefulWidget {
/// The callback invoked when the tile is tapped.
final VoidCallback onTap;
/// The callback invoked when the players are edited
final VoidCallback? onPlayerEdited;
/// Optional width for the tile.
final double? width;
/// Whether to display the tile in a compact mode
final bool compact;
@override
State<MatchTile> createState() => _MatchTileState();
}
@@ -96,59 +106,40 @@ class _MatchTileState extends State<MatchTile> {
],
),
const SizedBox(height: 4),
] else if (widget.compact) ...[
Row(
children: [
const Icon(Icons.person, size: 16, color: Colors.grey),
const SizedBox(width: 6),
Expanded(
child: Text(
'${match.players.length} ${loc.players}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
] else ...[
const SizedBox(height: 8),
],
// Game + Ruleset Badge
GameLabel(
title: match.game.name,
description: translateRulesetToString(
match.game.ruleset,
context,
if (!widget.compact)
GameLabel(
title: match.game.name,
description: translateRulesetToString(
match.game.ruleset,
context,
),
color: match.game.color,
),
color: match.game.color,
),
const SizedBox(height: 12),
// Winner / In Progress Info
if (match.isTeamMatch && match.mvt.isNotEmpty) ...[
// MVT Display for team matches
Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 12,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.green.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
getMvpIcon(),
const SizedBox(width: 8),
Expanded(
child: Text(
getMvtText(loc),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: CustomTheme.textColor,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 12),
] else if (match.mvp.isNotEmpty) ...[
// MVP Display for player matches
if (match.mvp.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
@@ -182,7 +173,6 @@ class _MatchTileState extends State<MatchTile> {
),
const SizedBox(height: 12),
] else ...[
// Match in progress display
Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
@@ -221,46 +211,8 @@ class _MatchTileState extends State<MatchTile> {
const SizedBox(height: 12),
],
if (match.teams != null &&
match.teams!.isNotEmpty &&
match.isTeamMatch) ...[
// Team display
Text(
loc.teams,
style: const TextStyle(
fontSize: 13,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
final useSingleColumn = match.teams!.any(
(team) => team.name.length > 10,
);
const spacing = 8.0;
final itemWidth = useSingleColumn
? constraints.maxWidth
: (constraints.maxWidth - spacing) / 2;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: match.teams!.map((team) {
return TeamCard(
team: team,
compact: true,
width: itemWidth,
);
}).toList(),
);
},
),
const SizedBox(height: 12),
] else if (players.isNotEmpty) ...[
// Player display
// Players List
if (players.isNotEmpty && widget.compact == false) ...[
Text(
loc.players,
style: const TextStyle(
@@ -277,17 +229,23 @@ class _MatchTileState extends State<MatchTile> {
return TextIconTile(
text: player.name,
suffixText: getNameCountText(player),
iconEnabled: false,
onTileTap: () {
Navigator.push(
context,
adaptivePageRoute(
builder: (context) => PlayerDetailView(
player: player,
callback: () {
widget.onPlayerEdited?.call();
},
),
),
);
},
);
}).toList(),
),
] else ...[
Text(
loc.no_players_available,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.hintColor,
),
),
],
],
),
@@ -313,7 +271,6 @@ class _MatchTileState extends State<MatchTile> {
}
}
// Returns the appropriate text based on the match's ruleset and MVP.
String getMvpText(AppLocalizations loc) {
if (widget.match.mvp.isEmpty) return '';
final ruleset = widget.match.game.ruleset;
@@ -337,41 +294,11 @@ class _MatchTileState extends State<MatchTile> {
return '${loc.winner}: n.A.';
}
// Returns the appropriate text based on the match's ruleset and MVT.
String getMvtText(AppLocalizations loc) {
if (widget.match.mvt.isEmpty) return '';
final ruleset = widget.match.game.ruleset;
switch (ruleset) {
case Ruleset.singleWinner:
return '${loc.winner}: ${widget.match.mvt.first.name}';
case Ruleset.singleLoser:
return '${loc.loser}: ${widget.match.mvt.first.name}';
case Ruleset.highestScore:
case Ruleset.lowestScore:
final mvt = widget.match.mvt;
final mvtScore =
widget.match.teams!
.firstWhere((team) => team.id == mvt.first.id)
.score ??
0;
final mvtNames = mvt.map((team) => team.name).join(', ');
return '${loc.winner}: $mvtNames (${getPointLabel(loc, mvtScore)})';
case Ruleset.placement:
return '${loc.winner}: ${widget.match.mvt.first.name}';
case Ruleset.multipleWinners:
final mvtNames = widget.match.mvt.map((team) => team.name).join(', ');
return '${loc.winners}: $mvtNames';
}
}
// Returns the appropriate icon based on the match's ruleset.
Icon getMvpIcon() {
final icon = getRulesetIcon(widget.match.game.ruleset);
switch (widget.match.game.ruleset) {
case Ruleset.singleWinner:
case Ruleset.multipleWinners:
return Icon(icon, size: 20, color: Colors.amber);
case Ruleset.singleLoser:
return Icon(icon, size: 20, color: Colors.blue);
@@ -379,6 +306,8 @@ class _MatchTileState extends State<MatchTile> {
return Icon(icon, size: 20, color: Colors.orange);
case Ruleset.highestScore:
return Icon(icon, size: 20, color: Colors.green);
case Ruleset.multipleWinners:
return Icon(icon, size: 20, color: Colors.amber);
case Ruleset.placement:
return Icon(icon, size: 20, color: Colors.deepOrangeAccent);
}

View File

@@ -1,8 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fluttericon/rpg_awesome_icons.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
@@ -21,8 +25,11 @@ class StatisticsTile extends StatelessWidget {
required this.title,
required this.width,
required this.values,
required this.itemCount,
required this.barColor,
required this.displayCount,
this.selectedGroups,
this.selectedGames,
this.showAllValues = false,
});
/// The icon displayed next to the title.
@@ -37,12 +44,16 @@ class StatisticsTile extends StatelessWidget {
/// A list of tuples containing labels and their corresponding numeric values.
final List<(Player, num)> values;
/// The maximum number of items to display.
final int itemCount;
/// The color of the bars representing the values.
final Color barColor;
// statistic data
final int displayCount;
final List<Group>? selectedGroups;
final List<Game>? selectedGames;
final bool showAllValues;
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
@@ -52,91 +63,202 @@ class StatisticsTile extends StatelessWidget {
title: title,
icon: icon,
content: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Visibility(
visible: values.isNotEmpty,
// No data avaiable message
replacement: Center(
heightFactor: 4,
child: Text(loc.no_data_available),
),
// Bar chart
child: LayoutBuilder(
builder: (context, constraints) {
final maxBarWidth = constraints.maxWidth * 0.65;
final maxBarWidth = constraints.maxWidth * 0.8;
// If displayCount wasnt provided, take all values
final valuesShown = showAllValues
? values.length
: min(values.length, displayCount);
final displayValues = values.take(valuesShown).toList();
final maxVal = displayValues.isNotEmpty
? displayValues.fold<num>(
0,
(currentMax, entry) =>
entry.$2 > currentMax ? entry.$2 : currentMax,
)
: 0;
return Column(
children: List.generate(min(values.length, itemCount), (index) {
/// The maximum wins among all players
final maxMatches = values.isNotEmpty ? values[0].$2 : 0;
children: [
// Bars
...List.generate(valuesShown, (index) {
/// Fraction of wins
final double fraction = (maxVal > 0)
? (displayValues[index].$2 / maxVal)
: 0.0;
/// Fraction of wins
final double fraction = (maxMatches > 0)
? (values[index].$2 / maxMatches)
: 0.0;
/// Calculated width for current the bar
final double barWidth = (maxBarWidth * fraction).clamp(
0.0,
maxBarWidth,
);
/// Calculated width for current the bar
final double barWidth = maxBarWidth * fraction;
final barClr = index >= displayCount
? barColor.withAlpha(150)
: barColor;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Stack(
children: [
Container(
height: 24,
width: barWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: barColor,
),
),
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: values[index].$1.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: getNameCountText(values[index].$1),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: CustomTheme.textColor.withAlpha(
150,
),
),
),
],
var textClr = barColor.computeLuminance() > 0.5
? const Color(0xFF101010)
: CustomTheme.textColor;
textClr = textClr.withAlpha(
index >= displayCount ? 220 : 255,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
width: maxBarWidth,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Bar
Container(
height: 24,
width: barWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: barClr,
),
),
),
),
],
),
const Spacer(),
Center(
child: Text(
values[index].$2 <= 1 && values[index].$2 is double
? values[index].$2.toStringAsFixed(2)
: values[index].$2.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
// Player
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: RichText(
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: displayValues[index].$1.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: textClr,
),
),
TextSpan(
text: getNameCountText(
displayValues[index].$1,
),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color:
(barColor ==
getColorFromAppColor(
AppColor.yellow,
)
? const Color(
0xFF101010,
)
: CustomTheme.textColor)
.withAlpha(150),
),
),
],
),
),
),
],
),
),
),
],
const Spacer(),
// Value
Center(
child: Text(
displayValues[index].$2 <= 1 &&
displayValues[index].$2 is double
? displayValues[index].$2.toStringAsFixed(2)
: displayValues[index].$2.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}),
// Group & Game info
if (hasGame || hasGroup)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4,
runSpacing: 4,
children: [
// Game
if (hasGroup)
Row(
spacing: 8,
children: [
const Icon(
RpgAwesome.clovers_card,
color: CustomTheme.hintColor,
size: 20,
),
Text(
getGameText(selectedGames!),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: CustomTheme.hintColor,
),
overflow: TextOverflow.ellipsis,
),
],
),
if (hasGroup && hasGame) const SizedBox(width: 20),
// Group
if (hasGroup)
Row(
spacing: 8,
children: [
const Icon(
Icons.groups,
color: CustomTheme.hintColor,
),
Text(
getGroupText(selectedGroups!),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: CustomTheme.hintColor,
),
overflow: TextOverflow.ellipsis,
),
],
),
],
),
),
);
}),
],
);
},
),
@@ -144,4 +266,24 @@ class StatisticsTile extends StatelessWidget {
),
);
}
String getGroupText(List<Group> groups) {
var text = groups[0].name;
if (groups.length > 1) {
return '$text + ${groups.length - 1}';
}
return text;
}
String getGameText(List<Game> games) {
var text = games[0].name;
if (games.length > 1) {
return '$text + ${games.length - 1}';
}
return text;
}
bool get hasGroup => selectedGroups != null && selectedGroups!.isNotEmpty;
bool get hasGame => selectedGames != null && selectedGames!.isNotEmpty;
}

View File

@@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttericon/font_awesome_icons.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/constants.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
class TeamCreationTile extends StatefulWidget {
const TeamCreationTile({
super.key,
required this.color,
required this.controller,
required this.hintText,
this.onDelete,
this.onColorSelection,
});
final AppColor color;
final TextEditingController controller;
final String hintText;
final VoidCallback? onDelete;
final ValueChanged<AppColor>? onColorSelection;
@override
State<TeamCreationTile> createState() => _TeamCreationTileState();
}
class _TeamCreationTileState extends State<TeamCreationTile> {
final teamColors = List.generate(
AppColor.values.length,
(index) => getTeamColor(index),
);
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Container(
margin: CustomTheme.standardMargin,
decoration: CustomTheme.standardBoxDecoration,
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name input + delete icon
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: TextInputField(
controller: widget.controller,
hintText: widget.hintText,
maxLength: Constants.MAX_TEAM_NAME_LENGTH,
),
),
HapticIconButton(
icon: const Icon(FontAwesome.trash),
color: CustomTheme.textColor,
iconSize: 25,
onPressed: widget.onDelete,
),
],
),
const SizedBox(height: 12),
// Color label
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
loc.color,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: CustomTheme.textColor,
),
),
),
const SizedBox(height: 8),
// Color picker
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: teamColors.map((color) {
final isSelected = widget.color == color;
return GestureDetector(
onTap: () {
widget.onColorSelection?.call(color);
},
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: getColorFromGameColor(color),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Colors.white
: Colors.transparent,
width: 3,
),
),
child: isSelected
? const Icon(
Icons.check,
size: 18,
color: Colors.white,
)
: null,
),
);
}).toList(),
),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -11,7 +11,6 @@ class TextIconListTile extends StatelessWidget {
required this.text,
this.suffixText = '',
this.icon,
this.color,
this.onPressed,
});
@@ -24,8 +23,6 @@ class TextIconListTile extends StatelessWidget {
/// The icon to display in the tile.
final IconData? icon;
final Color? color;
/// The callback to be invoked when the icon is pressed.
final VoidCallback? onPressed;
@@ -34,17 +31,7 @@ class TextIconListTile extends StatelessWidget {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color:
Color.lerp(CustomTheme.onBoxColor, color?.withAlpha(10), 0.1) ??
CustomTheme.boxColor,
border: Border.all(
color: color ?? CustomTheme.boxBorderColor,
width: color != null ? 2 : 1,
strokeAlign: BorderSide.strokeAlignCenter,
),
borderRadius: CustomTheme.standardBorderRadiusAll,
),
decoration: CustomTheme.standardBoxDecoration,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,

View File

@@ -4,14 +4,15 @@ import 'package:tallee/core/custom_theme.dart';
class TextIconTile extends StatelessWidget {
/// A tile widget that displays text with an optional icon that can be tapped.
/// - [text]: The text to display in the tile.
/// - [iconEnabled]: A boolean to determine if the icon should be displayed.
/// - [onIconTap]: The callback to be invoked when the icon is tapped.
/// - [icon]: Optional custom icon. Defaults to [Icons.close].
const TextIconTile({
super.key,
required this.text,
this.suffixText = '',
this.iconEnabled = true,
this.onIconTap,
this.icon = Icons.close,
this.onTileTap,
});
/// The text to display in the tile.
@@ -19,57 +20,64 @@ class TextIconTile extends StatelessWidget {
final String suffixText;
/// A boolean to determine if the icon should be displayed.
final bool iconEnabled;
/// The callback to be invoked when the icon is tapped.
final VoidCallback? onIconTap;
/// The icon to display. Defaults to [Icons.close].
final IconData icon;
/// The callback to be invoked when the tile is tapped.
final VoidCallback? onTileTap;
@override
Widget build(BuildContext context) {
final iconEnabled = onIconTap != null;
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: CustomTheme.onBoxColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
if (iconEnabled) const SizedBox(width: 3),
Flexible(
child: RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
return GestureDetector(
onTap: onTileTap,
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: CustomTheme.onBoxColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
if (iconEnabled) const SizedBox(width: 3),
Flexible(
child: RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextSpan(
text: suffixText,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: CustomTheme.textColor.withAlpha(120),
TextSpan(
text: suffixText,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: CustomTheme.textColor.withAlpha(120),
),
),
),
],
],
),
),
),
),
if (iconEnabled) ...<Widget>[
const SizedBox(width: 3),
GestureDetector(onTap: onIconTap, child: Icon(icon, size: 20)),
if (iconEnabled) ...<Widget>[
const SizedBox(width: 3),
GestureDetector(
onTap: onIconTap,
child: const Icon(Icons.close, size: 20),
),
],
],
],
),
),
);
}

View File

@@ -20,6 +20,7 @@ class DataTransferService {
static Future<void> deleteAllData(BuildContext context) async {
final db = Provider.of<AppDatabase>(context, listen: false);
await db.statisticDao.deleteAllStatistics();
await db.matchDao.deleteAllMatches();
await db.teamDao.deleteAllTeams();
await db.groupDao.deleteAllGroups();
@@ -200,9 +201,13 @@ class DataTransferService {
.map((id) => playerById[id])
.whereType<Player>()
.toList();
final team = Team.fromJson(map);
return team.copyWith(members: members);
return Team(
id: map['id'] as String,
name: map['name'] as String,
members: members,
createdAt: DateTime.parse(map['createdAt'] as String),
);
}).toList();
}
@@ -227,7 +232,6 @@ class DataTransferService {
final endedAt = map['endedAt'] != null
? DateTime.parse(map['endedAt'] as String)
: null;
final isTeamMatch = map['isTeamMatch'] as bool;
final notes = map['notes'] as String? ?? '';
final scoresJson = map['scores'] as Map<String, dynamic>? ?? {};
final scores = scoresJson.map(
@@ -259,7 +263,6 @@ class DataTransferService {
game: game,
group: group,
players: players,
isTeamMatch: isTeamMatch,
teams: teams.isEmpty ? null : teams,
createdAt: createdAt,
endedAt: endedAt,

View File

@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "10.0.1"
animated_custom_dropdown:
dependency: "direct main"
description:
name: animated_custom_dropdown
sha256: "5a72dc209041bb53f6c7164bc2e366552d5197cdb032b1c9b2c36e3013024486"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
arb_utils:
dependency: "direct dev"
description:
@@ -353,6 +361,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.8"
dropdown_flutter:
dependency: "direct main"
description:
name: dropdown_flutter
sha256: "5ae3d05d768d0bb6030ff735e6b4b93f7b29be3cf3bec7c86cd4f444c8f067ff"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
equatable:
dependency: transitive
description:

View File

@@ -1,19 +1,21 @@
name: tallee
description: "Tracking App for Card Games"
publish_to: 'none'
version: 0.0.33+340
version: 0.0.33+281
environment:
sdk: ^3.8.1
sdk: ^3.12.0
dependencies:
animated_custom_dropdown: ^3.1.1
clock: ^1.1.2
collection: ^1.19.1
cupertino_icons: ^1.0.6
drift: ^2.27.0
drift_flutter: ^0.2.4
dropdown_flutter: ^1.0.3
cupertino_icons: ^1.0.9
drift: ^2.33.0
drift_flutter: ^0.3.0
file_picker: ^11.0.2
file_saver: ^0.3.1
file_saver: ^0.4.0
flutter:
sdk: flutter
flutter_localizations:
@@ -24,20 +26,20 @@ dependencies:
font_awesome_flutter: ^11.0.0
intl: any
json_schema: ^5.2.2
package_info_plus: ^9.0.0
package_info_plus: ^9.0.1
path_provider: ^2.1.5
provider: ^6.1.5
skeletonizer: ^2.1.0+1
skeletonizer: ^2.1.3
url_launcher: ^6.3.2
uuid: ^4.5.2
uuid: ^4.5.3
dev_dependencies:
arb_utils: ^0.11.0
flutter_test:
sdk: flutter
build_runner: ^2.7.0
dart_pubspec_licenses: ^3.0.14
drift_dev: ^2.27.0
build_runner: ^2.15.0
dart_pubspec_licenses: ^3.2.0
drift_dev: ^2.33.0
flutter_lints: ^6.0.0
flutter:

View File

@@ -194,6 +194,31 @@ void main() {
expect(allGroups, isEmpty);
});
test('getGroupsByPlayer() works correctly', () async {
await database.groupDao.addGroupsAsList(
groups: [testGroup1, testGroup2],
);
final groups = await database.groupDao.getGroupsByPlayer(
playerId: testPlayer2.id,
);
expect(groups, hasLength(2));
expect(groups.any((group) => group.id == testGroup1.id), isTrue);
expect(groups.any((group) => group.id == testGroup2.id), isTrue);
});
test(
'getGroupsByPlayer() returns empty list for non-existent player',
() async {
final groups = await database.groupDao.getGroupsByPlayer(
playerId: 'non-existent-player-id',
);
expect(groups, isEmpty);
},
);
test('addGroupsAsList() with duplicate groups only adds once', () async {
await database.groupDao.addGroupsAsList(
groups: [testGroup1, testGroup1, testGroup1],

View File

@@ -260,6 +260,34 @@ void main() {
expect(match.group!.id, testGroup1.id);
});
test('getMatchesByPlayer() works correctly', () async {
await database.matchDao.addMatchesAsList(
matches: [testMatch1, testMatch2],
);
final matches = await database.matchDao.getMatchesByPlayer(
playerId: testPlayer1.id,
);
expect(matches, hasLength(1));
expect(matches.first.id, testMatch2.id);
expect(
matches.first.players.any((p) => p.id == testPlayer1.id),
isTrue,
);
});
test(
'getMatchesByPlayer() returns empty list for non-existent player',
() async {
final matches = await database.matchDao.getMatchesByPlayer(
playerId: 'non-existing-player-id',
);
expect(matches, isEmpty);
},
);
test('getMatchCount() works correctly', () async {
var count = await database.matchDao.getMatchCount();
expect(count, 0);
@@ -507,36 +535,34 @@ void main() {
deleted = await database.matchDao.deleteAllMatches();
expect(deleted, isFalse);
});
});
test('deleteMatchesByGame() deletes all matches for a game', () async {
await database.matchDao.addMatch(match: testMatch1);
await database.matchDao.addMatch(match: testMatch2);
test('deleteMatchesByGame() deletes all matches for a game', () async {
await database.matchDao.addMatch(match: testMatch1);
await database.matchDao.addMatch(match: testMatch2);
var count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 2);
var count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 2);
final deletedCount = await database.matchDao.deleteMatchesByGame(
gameId: testGame.id,
);
expect(deletedCount, 2);
final deletedCount = await database.matchDao.deleteMatchesByGame(
gameId: testGame.id,
);
expect(deletedCount, 2);
count = await database.matchDao.getMatchCountByGame(
gameId: testGame.id,
);
expect(count, 0);
count = await database.matchDao.getMatchCountByGame(gameId: testGame.id);
expect(count, 0);
final allMatches = await database.matchDao.getAllMatches();
expect(allMatches, isEmpty);
});
final allMatches = await database.matchDao.getAllMatches();
expect(allMatches, isEmpty);
});
test('deleteMatchesByGame() returns 0 for non-existent game', () async {
final deletedCount = await database.matchDao.deleteMatchesByGame(
gameId: 'non-existent-game-id',
);
expect(deletedCount, 0);
});
test('deleteMatchesByGame() returns 0 for non-existent game', () async {
final deletedCount = await database.matchDao.deleteMatchesByGame(
gameId: 'non-existent-game-id',
);
expect(deletedCount, 0);
});
});
}

View File

@@ -1,7 +1,7 @@
import 'dart:core' hide Match;
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tallee/core/enums.dart';
@@ -327,200 +327,5 @@ void main() {
expect(deleted, isFalse);
});
});
group('SCORE', () {
test('updateTeamScore() works correctly', () async {
await database.matchDao.addMatch(match: testMatch1);
final updated = await database.teamDao.updateTeamScore(
teamId: testTeam1.id,
matchId: testMatch1.id,
score: 5,
);
expect(updated, isTrue);
final team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, 5);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 5);
}
});
test('set-/removeWinnerTeam() works correctly', () async {
await database.matchDao.addMatch(match: testMatch1);
final set = await database.teamDao.setWinnerTeam(
teamId: testTeam1.id,
matchId: testMatch1.id,
);
expect(set, isTrue);
var team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, 1);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 1);
}
final removed = await database.teamDao.removeWinnerTeam(
matchId: testMatch1.id,
);
expect(removed, isTrue);
team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, isNull);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNull);
}
});
test('set-/removeLoserTeam() works correctly', () async {
await database.matchDao.addMatch(match: testMatch1);
final set = await database.teamDao.setLoserTeam(
teamId: testTeam1.id,
matchId: testMatch1.id,
);
expect(set, isTrue);
var team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, 0);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 0);
}
final removed = await database.teamDao.removeLoserTeam(
matchId: testMatch1.id,
);
expect(removed, isTrue);
team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, isNull);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNull);
}
});
test('set-/removeWinnerTeams() works correctly', () async {
await database.matchDao.addMatch(match: testMatch1);
final set = await database.teamDao.setWinnerTeams(
winners: [testTeam1, testTeam2],
matchId: testMatch1.id,
);
expect(set, isTrue);
// check both teams got the winner score
var team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, 1);
team = await database.teamDao.getTeamById(teamId: testTeam2.id);
expect(team.score, 1);
// check all members of both teams got the winner score
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 1);
}
for (final member in testTeam2.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 1);
}
final removed = await database.teamDao.removeWinnerTeam(
matchId: testMatch1.id,
);
expect(removed, isTrue);
team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, isNull);
team = await database.teamDao.getTeamById(teamId: testTeam2.id);
expect(team.score, isNull);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNull);
}
for (final member in testTeam2.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNull);
}
});
test('setTeamPlacements() works correctly', () async {
await database.matchDao.addMatch(match: testMatch1);
final set = await database.teamDao.setTeamPlacements(
teams: [testTeam1, testTeam2],
matchId: testMatch1.id,
);
expect(set, isTrue);
var team = await database.teamDao.getTeamById(teamId: testTeam1.id);
expect(team.score, 2);
team = await database.teamDao.getTeamById(teamId: testTeam2.id);
expect(team.score, 1);
for (final member in testTeam1.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 2);
}
for (final member in testTeam2.members) {
final entry = await database.scoreEntryDao.getScore(
playerId: member.id,
matchId: testMatch1.id,
);
expect(entry, isNotNull);
expect(entry!.score, 1);
}
});
});
});
}

View File

@@ -233,6 +233,95 @@ void main() {
expect(allPlayers, isEmpty);
});
test('updatePlayerName() sets correct nameCount with 2 player', () async {
await database.playerDao.addPlayer(player: testPlayer1);
await database.playerDao.addPlayer(player: testPlayer2);
final newName = testPlayer1.name;
await database.playerDao.updatePlayerName(
playerId: testPlayer2.id,
name: newName,
);
var player = await database.playerDao.getPlayerById(
playerId: testPlayer1.id,
);
expect(player.nameCount, 1);
player = await database.playerDao.getPlayerById(
playerId: testPlayer2.id,
);
expect(player.nameCount, 2);
await database.playerDao.updatePlayerName(
playerId: testPlayer1.id,
name: 'different name',
);
player = await database.playerDao.getPlayerById(
playerId: testPlayer1.id,
);
expect(player.nameCount, 0);
player = await database.playerDao.getPlayerById(
playerId: testPlayer2.id,
);
expect(player.nameCount, 0);
});
test('updatePlayerName() sets correct nameCount with 3 player', () async {
await database.playerDao.addPlayersAsList(
players: [testPlayer1, testPlayer2, testPlayer3],
);
// Changing both names to player 1's name
final newName = testPlayer1.name;
await database.playerDao.updatePlayerName(
playerId: testPlayer2.id,
name: newName,
);
await database.playerDao.updatePlayerName(
playerId: testPlayer3.id,
name: newName,
);
var player = await database.playerDao.getPlayerById(
playerId: testPlayer1.id,
);
expect(player.nameCount, 1);
player = await database.playerDao.getPlayerById(
playerId: testPlayer2.id,
);
expect(player.nameCount, 2);
player = await database.playerDao.getPlayerById(
playerId: testPlayer3.id,
);
expect(player.nameCount, 3);
// Changing the middle players name
await database.playerDao.updatePlayerName(
playerId: testPlayer2.id,
name: 'different name',
);
player = await database.playerDao.getPlayerById(
playerId: testPlayer1.id,
);
expect(player.nameCount, 1);
player = await database.playerDao.getPlayerById(
playerId: testPlayer2.id,
);
expect(player.nameCount, 0);
player = await database.playerDao.getPlayerById(
playerId: testPlayer3.id,
);
expect(player.nameCount, 2);
});
test('updatePlayerDescription() works correctly', () async {
await database.playerDao.addPlayer(player: testPlayer1);
@@ -372,14 +461,22 @@ void main() {
final player1 = Player(name: testPlayer1.name, description: '');
await database.playerDao.addPlayer(player: player1);
final player2 = Player(name: testPlayer1.name, description: '');
await database.playerDao.addPlayer(player: player2);
var players = await database.playerDao.getAllPlayers();
expect(players.length, 2);
expect(players.length, 3);
players.sort((a, b) => a.nameCount.compareTo(b.nameCount));
for (int i = 0; i < players.length - 1; i++) {
expect(players[i].nameCount, i + 1);
}
// ids are correct in the right order
expect(players[0].id, testPlayer1.id);
expect(players[1].id, player1.id);
expect(players[2].id, player2.id);
},
);
@@ -404,24 +501,62 @@ void main() {
for (int i = 0; i < players.length - 1; i++) {
expect(players[i].nameCount, i + 1);
}
// ids are correct in the right order
expect(players[0].id, testPlayer1.id);
expect(players[1].id, player1.id);
expect(players[2].id, player2.id);
expect(players[3].id, player3.id);
},
);
test('getNameCount works correctly', () async {
test('getNameCount works correctly', () async {
final player1 = Player(name: testPlayer1.name);
final player2 = Player(name: testPlayer1.name);
final player3 = Player(name: testPlayer1.name);
await database.playerDao.addPlayersAsList(
players: [testPlayer1, player2, player3],
await database.playerDao.addPlayer(player: testPlayer1);
var nameCount = await database.playerDao.getNameCount(
name: testPlayer1.name,
);
final nameCount = await database.playerDao.getNameCount(
expect(nameCount, 1);
await database.playerDao.addPlayersAsList(players: [player1, player2]);
nameCount = await database.playerDao.getNameCount(
name: testPlayer1.name,
);
expect(nameCount, 3);
});
test('calculateNameCount works correctly', () async {
final player1 = Player(name: testPlayer1.name);
final player2 = Player(name: testPlayer1.name);
// Case 1: No existing players with the name
var nameCount = await database.playerDao.calculateNameCount(
name: testPlayer1.name,
);
expect(nameCount, 0);
// Case 2: One existing player with the name. Should return 2 for
// the new player
await database.playerDao.addPlayer(player: testPlayer1);
nameCount = await database.playerDao.calculateNameCount(
name: testPlayer1.name,
);
expect(nameCount, 2);
// Case 3: Multiple existing players with the name. Should return count + 1
await database.playerDao.addPlayersAsList(players: [player1, player2]);
nameCount = await database.playerDao.calculateNameCount(
name: testPlayer1.name,
);
expect(nameCount, 4);
});
test('updateNameCount works correctly', () async {
await database.playerDao.addPlayer(player: testPlayer1);
@@ -441,14 +576,24 @@ void main() {
final player2 = Player(name: testPlayer1.name, description: '');
final player3 = Player(name: testPlayer1.name, description: '');
await database.playerDao.addPlayersAsList(
players: [testPlayer1, player2, player3],
);
final player = await database.playerDao.getPlayerWithHighestNameCount(
await database.playerDao.addPlayer(player: testPlayer1);
var player = await database.playerDao.getPlayerWithHighestNameCount(
name: testPlayer1.name,
);
expect(player, isNotNull);
expect(player!.nameCount, 0);
await database.playerDao.addPlayer(player: player2);
player = await database.playerDao.getPlayerWithHighestNameCount(
name: testPlayer1.name,
);
expect(player, isNotNull);
expect(player!.nameCount, 2);
await database.playerDao.addPlayer(player: player3);
player = await database.playerDao.getPlayerWithHighestNameCount(
name: testPlayer1.name,
);
expect(player, isNotNull);
expect(player!.nameCount, 3);
});
@@ -460,32 +605,6 @@ void main() {
expect(player, isNull);
});
test('calculateNameCount works correctly', () async {
// Case 1: No existing players with the name
var count = await database.playerDao.calculateNameCount(
name: testPlayer1.name,
);
expect(count, 0);
// Case 2: One existing player with the name. Should update that
// player's nameCount to 1 and return 2 for the new player
await database.playerDao.addPlayer(player: testPlayer1);
count = await database.playerDao.calculateNameCount(
name: testPlayer1.name,
);
expect(count, 2);
// Case 3: Multiple existing players with the name.
final player2 = Player(name: testPlayer1.name, nameCount: count);
await database.playerDao.addPlayer(player: player2);
count = await database.playerDao.calculateNameCount(
name: testPlayer1.name,
);
expect(count, 3);
});
test('getPlayerWithHighestNameCount with non existing player', () async {
await database.playerDao.addPlayer(player: testPlayer1);
await database.playerDao.initializeNameCount(name: testPlayer1.name);

View File

@@ -0,0 +1,122 @@
import 'dart:core';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' hide isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/statistic.dart';
void main() {
late AppDatabase database;
late Player testPlayer1;
late Player testPlayer2;
late Player testPlayer3;
late Player testPlayer4;
late Player testPlayer5;
late Group testGroup1;
late Group testGroup2;
late Game testGame;
/*late Match testMatch1;
late Match testMatch2;
late Match testMatchOnlyPlayers;
late Match testMatchOnlyGroup;*/
final fixedDate = DateTime(2025, 19, 11, 00, 11, 23);
final fakeClock = Clock(() => fixedDate);
setUp(() async {
database = AppDatabase(
DatabaseConnection(
NativeDatabase.memory(),
// Recommended for widget tests to avoid test errors.
closeStreamsSynchronously: true,
),
);
withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana');
testPlayer5 = Player(name: 'Eve');
testGroup1 = Group(
name: 'Test Group 1',
description: '',
members: [testPlayer1, testPlayer2, testPlayer3],
);
testGroup2 = Group(
name: 'Test Group 2',
description: '',
members: [testPlayer4, testPlayer5],
);
testGame = Game(
name: 'Test Game',
ruleset: Ruleset.singleWinner,
description: 'A test game',
color: AppColor.blue,
icon: '',
);
/*testMatch1 = Match(
name: 'First Test Match',
game: testGame,
group: testGroup1,
players: [testPlayer4, testPlayer5],
scores: {testPlayer4.id: ScoreEntry(score: 1)},
);
testMatch2 = Match(
name: 'Second Test Match',
game: testGame,
group: testGroup2,
players: [testPlayer1, testPlayer2, testPlayer3],
);
testMatchOnlyPlayers = Match(
name: 'Test Match with Players',
game: testGame,
players: [testPlayer1, testPlayer2, testPlayer3],
);
testMatchOnlyGroup = Match(
name: 'Test Match with Group',
game: testGame,
group: testGroup2,
players: testGroup2.members,
);*/
});
await database.playerDao.addPlayersAsList(
players: [
testPlayer1,
testPlayer2,
testPlayer3,
testPlayer4,
testPlayer5,
],
);
await database.groupDao.addGroupsAsList(groups: [testGroup1, testGroup2]);
await database.gameDao.addGame(game: testGame);
});
tearDown(() async {
await database.close();
});
test('Adding/fetching statistic works correclty', () async {
final statistic = Statistic(
type: StatisticType.averageScore,
scopes: [StatisticScope.allPlayers, StatisticScope.selectedGames],
timeframe: Timeframe.allTime,
selectedGames: [testGame],
selectedGroups: [testGroup1],
);
final added = await database.statisticDao.addStatistic(
statistic: statistic,
);
expect(added, isTrue);
final fetched = await database.statisticDao.getStatisticById(statistic.id);
expect(fetched, isNotNull);
expect(fetched!.type, statistic.type);
});
}

View File

@@ -55,12 +55,7 @@ void main() {
members: [testPlayer1, testPlayer2],
);
testTeam = Team(
name: 'Test Team',
color: AppColor.yellow,
score: 5,
members: [testPlayer1, testPlayer2],
);
testTeam = Team(name: 'Test Team', members: [testPlayer1, testPlayer2]);
testMatch = Match(
name: 'Test Match',
@@ -142,6 +137,9 @@ void main() {
await database.playerDao.addPlayer(player: testPlayer2);
await database.gameDao.addGame(game: testGame);
await database.groupDao.addGroup(group: testGroup);
/*
await database.teamDao.addTeam(team: testTeam);
*/
await database.matchDao.addMatch(match: testMatch);
final ctx = await getContext(tester);
@@ -671,8 +669,6 @@ void main() {
'name': testTeam.name,
'memberIds': [testPlayer1.id],
'createdAt': testTeam.createdAt.toIso8601String(),
'color': testTeam.color.name,
'score': testTeam.score,
},
];
@@ -686,8 +682,6 @@ void main() {
expect(teams[0].name, testTeam.name);
expect(teams[0].members.length, 1);
expect(teams[0].members[0].id, testPlayer1.id);
expect(teams[0].color, testTeam.color);
expect(teams[0].score, testTeam.score);
});
test('parseTeamsFromJson() empty list', () {
@@ -724,9 +718,6 @@ void main() {
'gameId': testGame.id,
'groupId': testGroup.id,
'playerIds': [testPlayer1.id, testPlayer2.id],
'isTeamMatch': false,
'teams': null,
'scores': null,
'notes': testMatch.notes,
'createdAt': testMatch.createdAt.toIso8601String(),
},
@@ -782,9 +773,6 @@ void main() {
'name': testMatch.name,
'gameId': 'non-existent-game-id',
'playerIds': [testPlayer1.id],
'isTeamMatch': false,
'teams': null,
'scores': null,
'notes': '',
'createdAt': testMatch.createdAt.toIso8601String(),
},
@@ -816,9 +804,6 @@ void main() {
'gameId': testGame.id,
'groupId': null,
'playerIds': [testPlayer1.id],
'isTeamMatch': false,
'teams': null,
'scores': null,
'notes': '',
'createdAt': testMatch.createdAt.toIso8601String(),
},
@@ -849,9 +834,6 @@ void main() {
'name': testMatch.name,
'gameId': testGame.id,
'playerIds': [testPlayer1.id],
'isTeamMatch': false,
'teams': null,
'scores': null,
'notes': '',
'createdAt': testMatch.createdAt.toIso8601String(),
'endedAt': endedDate.toIso8601String(),
@@ -871,7 +853,7 @@ void main() {
});
});
test('validateJsonSchema() works correctly', () async {
test('validateJsonSchema()', () async {
final validJson = json.encode({
'players': [
{
@@ -915,15 +897,6 @@ void main() {
},
'createdAt': testMatch.createdAt.toIso8601String(),
'endedAt': null,
'isTeamMatch': true,
'teams': [
{
'id': testTeam.id,
'name': testTeam.name,
'memberIds': [testPlayer1.id, testPlayer2.id],
'createdAt': testTeam.createdAt.toIso8601String(),
},
],
},
],
});
@@ -931,28 +904,5 @@ void main() {
final isValid = await DataTransferService.validateJsonSchema(validJson);
expect(isValid, true);
});
testWidgets('validateJsonSchema() validates exported json file', (
tester,
) async {
await database.playerDao.addPlayer(player: testPlayer1);
await database.playerDao.addPlayer(player: testPlayer2);
await database.gameDao.addGame(game: testGame);
await database.groupDao.addGroup(group: testGroup);
await database.matchDao.addMatch(match: testMatch);
final ctx = await getContext(tester);
final jsonString = await DataTransferService.getAppDataAsJson(ctx);
expect(jsonString, isNotEmpty);
// Schema validation requires real async operations (rootBundle,
// HttpClient within json_schema). These must run via
// tester.runAsync, otherwise the test hangs due to a pending timer.
final isValid = await tester.runAsync(
() => DataTransferService.validateJsonSchema(jsonString),
);
expect(isValid, true);
});
});
}