MVP #141

Merged
flixcoo merged 705 commits from development into main 2026-01-09 12:55:50 +00:00
29 changed files with 952 additions and 115 deletions
Showing only changes of commit dd2024e96e - Show all commits

View File

@@ -8,6 +8,19 @@ class CustomTheme {
static Color onBoxColor = const Color(0xFF181818);
static Color boxBorder = const Color(0xFF272727);
static BoxDecoration standardBoxDecoration = BoxDecoration(
color: boxColor,
border: Border.all(color: boxBorder),
borderRadius: BorderRadius.circular(12),
);
static BoxDecoration highlightedBoxDecoration = BoxDecoration(
color: boxColor,
border: Border.all(color: primaryColor),
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: primaryColor.withAlpha(120), blurRadius: 12)],
);
static AppBarTheme appBarTheme = AppBarTheme(
backgroundColor: backgroundColor,
foregroundColor: Colors.white,

View File

@@ -22,3 +22,10 @@ enum ImportResult {
/// - [ExportResult.canceled]: The export operation was canceled by the user.
/// - [ExportResult.unknownException]: An exception occurred during export.
enum ExportResult { success, canceled, unknownException }
/// Different rulesets available for games
/// - [Ruleset.singleWinner]: The game is won by a single player
/// - [Ruleset.singleLoser]: The game is lost by a single player
/// - [Ruleset.mostPoints]: The player with the most points wins.
/// - [Ruleset.lastPoints]: The player with the fewest points wins.
enum Ruleset { singleWinner, singleLoser, mostPoints, lastPoints }

View File

@@ -78,7 +78,7 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
);
if (game.players != null) {
await db.playerDao.addPlayers(players: game.players!);
await db.playerDao.addPlayersAsList(players: game.players!);
for (final p in game.players ?? []) {
await db.playerGameDao.addPlayerToGame(
gameId: game.id,
@@ -89,12 +89,18 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
if (game.group != null) {
await db.groupDao.addGroup(group: game.group!);
await db.groupGameDao.addGroupToGame(game.id, game.group!.id);
await db.groupGameDao.addGroupToGame(
gameId: game.id,
groupId: game.group!.id,
);
}
});
}
Future<void> addGames({required List<Game> games}) async {
/// Adds multiple [Game]s to the database in a batch operation.
/// Also adds associated players and groups if they exist.
/// If the [games] list is empty, the method returns immediately.
Future<void> addGamesAsList({required List<Game> games}) async {
if (games.isEmpty) return;
await db.transaction(() async {
// Add all games in batch
@@ -253,4 +259,62 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
final rowsAffected = await query.go();
return rowsAffected > 0;
}
/// Sets the winner of the game with the given [gameId] to the player with
/// the given [winnerId].
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> setWinner({
required String gameId,
required String winnerId,
}) async {
final query = update(gameTable)..where((g) => g.id.equals(gameId));
final rowsAffected = await query.write(
GameTableCompanion(winnerId: Value(winnerId)),
);
return rowsAffected > 0;
}
/// Retrieves the winner of the game with the given [gameId].
/// Returns the [Player] who won the game, or `null` if no winner is set.
Future<Player?> getWinner({required String gameId}) async {
final query = select(gameTable)..where((g) => g.id.equals(gameId));
final result = await query.getSingleOrNull();
if (result == null || result.winnerId == null) {
return null;
}
final winner = await db.playerDao.getPlayerById(playerId: result.winnerId!);
return winner;
}
/// Removes the winner of the game with the given [gameId].
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> removeWinner({required String gameId}) async {
final query = update(gameTable)..where((g) => g.id.equals(gameId));
final rowsAffected = await query.write(
const GameTableCompanion(winnerId: Value(null)),
);
return rowsAffected > 0;
}
/// Checks if the game with the given [gameId] has a winner set.
/// Returns `true` if a winner is set, otherwise `false`.
Future<bool> hasWinner({required String gameId}) async {
final query = select(gameTable)
..where((g) => g.id.equals(gameId) & g.winnerId.isNotNull());
final result = await query.getSingleOrNull();
return result != null;
}
/// Changes the title of the game with the given [gameId] to [newName].
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> updateGameName({
required String gameId,
required String newName,
}) async {
final query = update(gameTable)..where((g) => g.id.equals(gameId));
final rowsAffected = await query.write(
GameTableCompanion(name: Value(newName)),
);
return rowsAffected > 0;
}
}

View File

@@ -84,7 +84,7 @@ class GroupDao extends DatabaseAccessor<AppDatabase> with _$GroupDaoMixin {
/// Adds multiple groups to the database.
/// Also adds the group's members to the [PlayerGroupTable].
Future<void> addGroups({required List<Group> groups}) async {
Future<void> addGroupsAsList({required List<Group> groups}) async {
if (groups.isEmpty) return;
await db.transaction(() async {
// Deduplicate groups by id - keep first occurrence

View File

@@ -12,7 +12,13 @@ class GroupGameDao extends DatabaseAccessor<AppDatabase>
/// Associates a group with a game by inserting a record into the
/// [GroupGameTable].
Future<void> addGroupToGame(String gameId, String groupId) async {
Future<void> addGroupToGame({
required String gameId,
required String groupId,
}) async {
if (await gameHasGroup(gameId: gameId)) {
throw Exception('Game already has a group');
}
await into(groupGameTable).insert(
GroupGameTableCompanion.insert(groupId: groupId, gameId: gameId),
mode: InsertMode.insertOrReplace,
@@ -76,4 +82,17 @@ class GroupGameDao extends DatabaseAccessor<AppDatabase>
final rowsAffected = await query.go();
return rowsAffected > 0;
}
/// Updates the group associated with a game to [newGroupId] based on
/// [gameId].
/// Returns `true` if more than 0 rows were affected, otherwise `false`.
Future<bool> updateGroupOfGame({
required String gameId,
required String newGroupId,
}) async {
final updatedRows =
await (update(groupGameTable)..where((g) => g.gameId.equals(gameId)))
.write(GroupGameTableCompanion(groupId: Value(newGroupId)));
return updatedRows > 0;
}
}

View File

@@ -50,7 +50,7 @@ class PlayerDao extends DatabaseAccessor<AppDatabase> with _$PlayerDaoMixin {
}
/// Adds multiple [players] to the database in a batch operation.
Future<bool> addPlayers({required List<Player> players}) async {
Future<bool> addPlayersAsList({required List<Player> players}) async {
if (players.isEmpty) return false;
await db.batch(

View File

@@ -79,4 +79,50 @@ class PlayerGameDao extends DatabaseAccessor<AppDatabase>
final rowsAffected = await query.go();
return rowsAffected > 0;
}
/// Updates the players associated with a game based on the provided
/// [newPlayer] list. It adds new players and removes players that are no
/// longer associated with the game.
Future<void> updatePlayersFromGame({
required String gameId,
required List<Player> newPlayer,
}) async {
final currentPlayers = await getPlayersOfGame(gameId: gameId);
// Create sets of player IDs for easy comparison
final currentPlayerIds = currentPlayers?.map((p) => p.id).toSet() ?? {};
final newPlayerIdsSet = newPlayer.map((p) => p.id).toSet();
// Determine players to add and remove
final playersToAdd = newPlayerIdsSet.difference(currentPlayerIds);
final playersToRemove = currentPlayerIds.difference(newPlayerIdsSet);
db.transaction(() async {
// Remove old players
if (playersToRemove.isNotEmpty) {
await (delete(playerGameTable)..where(
(pg) =>
pg.gameId.equals(gameId) &
pg.playerId.isIn(playersToRemove.toList()),
))
.go();
}
// Add new players
if (playersToAdd.isNotEmpty) {
final inserts = playersToAdd
.map(
(id) =>
PlayerGameTableCompanion.insert(playerId: id, gameId: gameId),
)
.toList();
await Future.wait(
inserts.map(
(c) => into(
playerGameTable,
).insert(c, mode: InsertMode.insertOrReplace),
),
);
}
});
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/data/dto/group.dart';
import 'package:game_tracker/presentation/widgets/tiles/group_tile.dart';
class ChooseGroupView extends StatefulWidget {
final List<Group> groups;
final int initialGroupIndex;
const ChooseGroupView({
super.key,
required this.groups,
required this.initialGroupIndex,
});
@override
State<ChooseGroupView> createState() => _ChooseGroupViewState();
}
class _ChooseGroupViewState extends State<ChooseGroupView> {
late int selectedGroupIndex;
@override
void initState() {
selectedGroupIndex = widget.initialGroupIndex;
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
backgroundColor: CustomTheme.backgroundColor,
scrolledUnderElevation: 0,
title: const Text(
'Choose Group',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
centerTitle: true,
),
body: ListView.builder(
padding: const EdgeInsets.only(bottom: 85),
itemCount: widget.groups.length,
itemBuilder: (BuildContext context, int index) {
return GestureDetector(
onTap: () {
setState(() {
selectedGroupIndex = index;
});
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
Navigator.of(context).pop(widget.groups[index]);
});
},
child: GroupTile(
group: widget.groups[index],
isHighlighted: selectedGroupIndex == index,
),
);
},
),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/core/enums.dart';
import 'package:game_tracker/presentation/widgets/tiles/ruleset_list_tile.dart';
class ChooseRulesetView extends StatefulWidget {
final List<(Ruleset, String, String)> rulesets;
final int initialRulesetIndex;
const ChooseRulesetView({
super.key,
required this.rulesets,
required this.initialRulesetIndex,
});
@override
State<ChooseRulesetView> createState() => _ChooseRulesetViewState();
}
class _ChooseRulesetViewState extends State<ChooseRulesetView> {
late int selectedRulesetIndex;
@override
void initState() {
selectedRulesetIndex = widget.initialRulesetIndex;
super.initState();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
initialIndex: 0,
child: Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
backgroundColor: CustomTheme.backgroundColor,
scrolledUnderElevation: 0,
title: const Text(
'Choose Ruleset',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
centerTitle: true,
),
body: Column(
children: [
Container(
color: CustomTheme.backgroundColor,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TabBar(
padding: const EdgeInsets.symmetric(horizontal: 5),
// Label Settings
labelStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
labelColor: Colors.white,
unselectedLabelStyle: const TextStyle(fontSize: 14),
unselectedLabelColor: Colors.white70,
// Indicator Settings
indicator: CustomTheme.standardBoxDecoration,
indicatorSize: TabBarIndicatorSize.tab,
indicatorWeight: 1,
indicatorPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 0,
),
// Divider Settings
dividerHeight: 0,
tabs: const [
Tab(text: 'Rulesets'),
Tab(text: 'Gametypes'),
],
),
),
const Divider(
indent: 30,
endIndent: 30,
thickness: 3,
radius: BorderRadius.all(Radius.circular(12)),
),
Expanded(
child: TabBarView(
children: [
ListView.builder(
padding: const EdgeInsets.only(bottom: 85),
itemCount: widget.rulesets.length,
itemBuilder: (BuildContext context, int index) {
return RulesetListTile(
onPressed: () async {
setState(() {
selectedRulesetIndex = index;
});
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
Navigator.of(
context,
).pop(widget.rulesets[index].$1);
});
},
title: widget.rulesets[index].$2,
description: widget.rulesets[index].$3,
isHighlighted: selectedRulesetIndex == index,
);
},
),
const Center(
child: Text(
'No gametypes available',
style: TextStyle(color: Colors.white70),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/core/enums.dart';
import 'package:game_tracker/data/db/database.dart';
import 'package:game_tracker/data/dto/game.dart';
import 'package:game_tracker/data/dto/group.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/views/main_menu/create_game/choose_group_view.dart';
import 'package:game_tracker/presentation/views/main_menu/create_game/choose_ruleset_view.dart';
import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart';
import 'package:game_tracker/presentation/widgets/player_selection.dart';
import 'package:game_tracker/presentation/widgets/text_input/text_input_field.dart';
import 'package:game_tracker/presentation/widgets/tiles/choose_tile.dart';
import 'package:provider/provider.dart';
class CreateGameView extends StatefulWidget {
const CreateGameView({super.key});
@override
State<CreateGameView> createState() => _CreateGameViewState();
}
class _CreateGameViewState extends State<CreateGameView> {
/// Reference to the app database
late final AppDatabase db;
/// Futures to load all groups and players from the database
late Future<List<Group>> _allGroupsFuture;
/// Future to load all players from the database
late Future<List<Player>> _allPlayersFuture;
/// Controller for the game name input field
final TextEditingController _gameNameController = TextEditingController();
/// List of all groups from the database
List<Group> groupsList = [];
/// List of all players from the database
List<Player> playerList = [];
/// The currently selected group
Group? selectedGroup;
/// The index of the currently selected group in [groupsList] to mark it in
/// the [ChooseGroupView]
int selectedGroupIndex = -1;
/// The currently selected ruleset
Ruleset? selectedRuleset;
/// The index of the currently selected ruleset in [rulesets] to mark it in
/// the [ChooseRulesetView]
int selectedRulesetIndex = -1;
/// The currently selected players
List<Player>? selectedPlayers;
/// List of available rulesets with their display names and descriptions
/// as tuples of (Ruleset, String, String)
List<(Ruleset, String, String)> rulesets = [
(
Ruleset.singleWinner,
'Single Winner',
'Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.',
),
(
Ruleset.singleLoser,
'Single Loser',
'Exactly one loser is determined; last place receives the penalty or consequence.',
),
(
Ruleset.mostPoints,
'Most Points',
'Traditional ruleset: the player with the most points wins.',
),
(
Ruleset.lastPoints,
'Least Points',
'Inverse scoring: the player with the fewest points wins.',
),
];
@override
void initState() {
super.initState();
db = Provider.of<AppDatabase>(context, listen: false);
_allGroupsFuture = db.groupDao.getAllGroups();
_allPlayersFuture = db.playerDao.getAllPlayers();
Future.wait([_allGroupsFuture, _allPlayersFuture]).then((result) async {
groupsList = result[0] as List<Group>;
playerList = result[1] as List<Player>;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
backgroundColor: CustomTheme.backgroundColor,
scrolledUnderElevation: 0,
title: const Text(
'Create new game',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
centerTitle: true,
),
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: TextInputField(
controller: _gameNameController,
hintText: 'Game name',
onChanged: (value) {
setState(() {});
},
),
),
ChooseTile(
title: 'Ruleset',
trailingText: selectedRuleset == null
? 'None'
: translateRulesetToString(selectedRuleset!),
onPressed: () async {
selectedRuleset = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChooseRulesetView(
rulesets: rulesets,
initialRulesetIndex: selectedRulesetIndex,
),
),
);
selectedRulesetIndex = rulesets.indexWhere(
(r) => r.$1 == selectedRuleset,
);
setState(() {});
},
),
ChooseTile(
title: 'Group',
trailingText: selectedGroup == null
? 'None'
: selectedGroup!.name,
onPressed: () async {
selectedGroup = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChooseGroupView(
groups: groupsList,
initialGroupIndex: selectedGroupIndex,
),
),
);
selectedGroupIndex = groupsList.indexWhere(
(g) => g.id == selectedGroup?.id,
);
setState(() {});
},
),
Expanded(
child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'),
initialPlayers: selectedGroup == null
? playerList
: playerList
.where(
(p) => !selectedGroup!.members.any(
(m) => m.id == p.id,
),
)
.toList(),
onChanged: (value) {
setState(() {
selectedPlayers = value;
});
},
),
),
CustomWidthButton(
text: 'Create game',
sizeRelativeToWidth: 0.95,
buttonType: ButtonType.primary,
onPressed: _enableCreateGameButton()
? () async {
Game game = Game(
name: _gameNameController.text.trim(),
createdAt: DateTime.now(),
group: selectedGroup!,
players: selectedPlayers,
);
// TODO: Replace with navigation to GameResultView()
print('Created game: $game');
Navigator.pop(context);
}
: null,
),
const SizedBox(height: 20),
],
),
),
);
}
/// Translates a [Ruleset] enum value to its corresponding string representation.
String translateRulesetToString(Ruleset ruleset) {
switch (ruleset) {
case Ruleset.singleWinner:
return 'Single Winner';
case Ruleset.singleLoser:
return 'Single Loser';
case Ruleset.mostPoints:
return 'Most Points';
case Ruleset.lastPoints:
return 'Least Points';
}
}
/// Determines whether the "Create Game" button should be enabled based on
/// the current state of the input fields.
bool _enableCreateGameButton() {
return _gameNameController.text.isNotEmpty &&
(selectedGroup != null ||
(selectedPlayers != null && selectedPlayers!.isNotEmpty)) &&
selectedRuleset != null;
}
}

View File

@@ -6,7 +6,7 @@ import 'package:game_tracker/data/dto/group.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart';
import 'package:game_tracker/presentation/widgets/player_selection.dart';
import 'package:game_tracker/presentation/widgets/text_input_field.dart';
import 'package:game_tracker/presentation/widgets/text_input/text_input_field.dart';
import 'package:provider/provider.dart';
class CreateGroupView extends StatefulWidget {

View File

@@ -5,10 +5,10 @@ import 'package:game_tracker/data/dto/game.dart';
import 'package:game_tracker/data/dto/group.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/views/main_menu/create_group_view.dart';
import 'package:game_tracker/presentation/widgets/tiles/game_history_tile.dart';
import 'package:game_tracker/presentation/widgets/top_centered_message.dart';
import 'package:game_tracker/presentation/widgets/app_skeleton.dart';
import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart';
import 'package:game_tracker/presentation/widgets/tiles/game_history_tile.dart';
import 'package:game_tracker/presentation/widgets/top_centered_message.dart';
import 'package:provider/provider.dart';
class GameHistoryView extends StatefulWidget {
@@ -38,9 +38,7 @@ class _GameHistoryViewState extends State<GameHistoryView> {
],
),
winner: Player(name: 'Skeleton Player 1'),
players: [
Player(name: 'Skeleton Player 6')
],
players: [Player(name: 'Skeleton Player 6')],
),
);
@@ -67,49 +65,53 @@ class _GameHistoryViewState extends State<GameHistoryView> {
children: [
FutureBuilder<List<Game>>(
future: _gameListFuture,
builder: (BuildContext context, AsyncSnapshot<List<Game>> snapshot) {
if (snapshot.hasError) {
return const Center(
child: TopCenteredMessage(
icon: Icons.report,
title: 'Error',
message: 'Game data could not be loaded',
),
);
}
if (snapshot.connectionState == ConnectionState.done &&
(!snapshot.hasData || snapshot.data!.isEmpty)) {
return const Center(
child: TopCenteredMessage(
icon: Icons.report,
title: 'Error',
message: 'No Games Available',
),
);
}
builder:
(BuildContext context, AsyncSnapshot<List<Game>> snapshot) {
if (snapshot.hasError) {
return const Center(
child: TopCenteredMessage(
icon: Icons.report,
title: 'Error',
message: 'Game data could not be loaded',
),
);
}
if (snapshot.connectionState == ConnectionState.done &&
(!snapshot.hasData || snapshot.data!.isEmpty)) {
return const Center(
child: TopCenteredMessage(
icon: Icons.report,
title: 'Error',
message: 'No Games Available',
),
);
}
final List<Game> games = (isLoading
? skeletonData
: (snapshot.data ?? [])
..sort((a, b) => b.createdAt.compareTo(a.createdAt)))
.toList();
final List<Game> games =
(isLoading ? skeletonData : (snapshot.data ?? [])
..sort(
(a, b) => b.createdAt.compareTo(a.createdAt),
))
.toList();
return AppSkeleton(
enabled: isLoading,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 85),
itemCount: games.length + 1,
itemBuilder: (BuildContext context, int index) {
if (index == games.length) {
return SizedBox(
height: MediaQuery.paddingOf(context).bottom - 80,
);
}
return GameHistoryTile(game: games[index]); // Placeholder
},
),
);
},
return AppSkeleton(
enabled: isLoading,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 85),
itemCount: games.length + 1,
itemBuilder: (BuildContext context, int index) {
if (index == games.length) {
return SizedBox(
height: MediaQuery.paddingOf(context).bottom - 80,
);
}
return GameHistoryTile(
game: games[index],
); // Placeholder
},
),
);
},
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom,
@@ -135,4 +137,4 @@ class _GameHistoryViewState extends State<GameHistoryView> {
),
);
}
}
}

View File

@@ -92,7 +92,6 @@ class _GroupsViewState extends State<GroupsView> {
);
},
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom,
child: CustomWidthButton(

View File

@@ -3,7 +3,7 @@ import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/data/db/database.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/widgets/app_skeleton.dart';
import 'package:game_tracker/presentation/widgets/custom_search_bar.dart';
import 'package:game_tracker/presentation/widgets/text_input/custom_search_bar.dart';
import 'package:game_tracker/presentation/widgets/tiles/text_icon_list_tile.dart';
import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart';
import 'package:game_tracker/presentation/widgets/top_centered_message.dart';
@@ -11,8 +11,13 @@ import 'package:provider/provider.dart';
class PlayerSelection extends StatefulWidget {
final Function(List<Player> value) onChanged;
final List<Player> initialPlayers;
const PlayerSelection({super.key, required this.onChanged});
const PlayerSelection({
super.key,
required this.onChanged,
this.initialPlayers = const [],
});
@override
State<PlayerSelection> createState() => _PlayerSelectionState();
@@ -46,9 +51,14 @@ class _PlayerSelectionState extends State<PlayerSelection> {
suggestedPlayers = skeletonData;
_allPlayersFuture.then((loadedPlayers) {
setState(() {
loadedPlayers.sort((a, b) => a.name.compareTo(b.name));
allPlayers = [...loadedPlayers];
suggestedPlayers = [...loadedPlayers];
if (widget.initialPlayers.isNotEmpty) {
allPlayers = [...widget.initialPlayers];
suggestedPlayers = [...widget.initialPlayers];
} else {
loadedPlayers.sort((a, b) => a.name.compareTo(b.name));
allPlayers = [...loadedPlayers];
suggestedPlayers = [...loadedPlayers];
}
});
});
}
@@ -58,11 +68,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
decoration: CustomTheme.standardBoxDecoration,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
class ChooseTile extends StatefulWidget {
final String title;
final VoidCallback? onPressed;
final String? trailingText;
const ChooseTile({
super.key,
required this.title,
this.trailingText,
this.onPressed,
});
@override
State<ChooseTile> createState() => _ChooseTileState();
}
class _ChooseTileState extends State<ChooseTile> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onPressed,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15),
decoration: CustomTheme.standardBoxDecoration,
child: Row(
children: [
Text(
widget.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const Spacer(),
if (widget.trailingText != null) Text(widget.trailingText!),
const SizedBox(width: 10),
const Icon(Icons.arrow_forward_ios, size: 16),
],
),
),
);
}
}

View File

@@ -4,20 +4,20 @@ import 'package:game_tracker/data/dto/group.dart';
import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart';
class GroupTile extends StatelessWidget {
const GroupTile({super.key, required this.group});
const GroupTile({super.key, required this.group, this.isHighlighted = false});
final Group group;
final bool isHighlighted;
@override
Widget build(BuildContext context) {
return Container(
return AnimatedContainer(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
decoration: isHighlighted
? CustomTheme.highlightedBoxDecoration
: CustomTheme.standardBoxDecoration,
duration: const Duration(milliseconds: 150),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -29,11 +29,7 @@ class _InfoTileState extends State<InfoTile> {
padding: widget.padding ?? const EdgeInsets.all(12),
height: widget.height,
width: widget.width ?? 380,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
),
decoration: CustomTheme.standardBoxDecoration,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,

View File

@@ -29,11 +29,7 @@ class _QuickInfoTileState extends State<QuickInfoTile> {
padding: widget.padding ?? const EdgeInsets.all(12),
height: widget.height ?? 110,
width: widget.width ?? 180,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
),
decoration: CustomTheme.standardBoxDecoration,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
class RulesetListTile extends StatelessWidget {
final String title;
final String description;
final VoidCallback? onPressed;
final bool isHighlighted;
const RulesetListTile({
super.key,
required this.title,
required this.description,
this.onPressed,
this.isHighlighted = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: AnimatedContainer(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
decoration: isHighlighted
? CustomTheme.highlightedBoxDecoration
: CustomTheme.standardBoxDecoration,
duration: const Duration(milliseconds: 200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
],
),
const SizedBox(height: 5),
Text(description, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 2.5),
],
),
),
);
}
}

View File

@@ -26,11 +26,7 @@ class SettingsListTile extends StatelessWidget {
child: Container(
margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
decoration: CustomTheme.standardBoxDecoration,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [

View File

@@ -18,11 +18,7 @@ class TextIconListTile extends StatelessWidget {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
decoration: CustomTheme.standardBoxDecoration,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,

View File

@@ -110,9 +110,9 @@ class DataTransferService {
.toList() ??
[];
await db.playerDao.addPlayers(players: importedPlayers);
await db.groupDao.addGroups(groups: importedGroups);
await db.gameDao.addGames(games: importedGames);
await db.playerDao.addPlayersAsList(players: importedPlayers);
await db.groupDao.addGroupsAsList(groups: importedGroups);
await db.gameDao.addGamesAsList(games: importedGames);
} else {
return ImportResult.invalidSchema;
}

View File

@@ -112,7 +112,7 @@ void main() {
});
test('Adding and fetching multiple games works correctly', () async {
await database.gameDao.addGames(
await database.gameDao.addGamesAsList(
games: [testGame1, testGame2, testGameOnlyGroup, testGameOnlyPlayers],
);
@@ -234,5 +234,97 @@ void main() {
gameCount = await database.gameDao.getGameCount();
expect(gameCount, 0);
});
test('Checking if game has winner works correclty', () async {
await database.gameDao.addGame(game: testGame1);
await database.gameDao.addGame(game: testGameOnlyGroup);
var hasWinner = await database.gameDao.hasWinner(gameId: testGame1.id);
expect(hasWinner, true);
hasWinner = await database.gameDao.hasWinner(
gameId: testGameOnlyGroup.id,
);
expect(hasWinner, false);
});
test('Fetching the winner of a game works correctly', () async {
await database.gameDao.addGame(game: testGame1);
final winner = await database.gameDao.getWinner(gameId: testGame1.id);
if (winner == null) {
fail('Winner is null');
} else {
expect(winner.id, testGame1.winner!.id);
expect(winner.name, testGame1.winner!.name);
expect(winner.createdAt, testGame1.winner!.createdAt);
}
});
test('Updating the winner of a game works correctly', () async {
await database.gameDao.addGame(game: testGame1);
final winner = await database.gameDao.getWinner(gameId: testGame1.id);
if (winner == null) {
fail('Winner is null');
} else {
expect(winner.id, testGame1.winner!.id);
expect(winner.name, testGame1.winner!.name);
expect(winner.createdAt, testGame1.winner!.createdAt);
expect(winner.id, testPlayer4.id);
expect(winner.id != testPlayer5.id, true);
}
await database.gameDao.setWinner(
gameId: testGame1.id,
winnerId: testPlayer5.id,
);
final newWinner = await database.gameDao.getWinner(gameId: testGame1.id);
if (newWinner == null) {
fail('New winner is null');
} else {
expect(newWinner.id, testPlayer5.id);
expect(newWinner.name, testPlayer5.name);
expect(newWinner.createdAt, testPlayer5.createdAt);
}
});
test('Removing a winner works correctly', () async {
await database.gameDao.addGame(game: testGame2);
var hasWinner = await database.gameDao.hasWinner(gameId: testGame2.id);
expect(hasWinner, true);
await database.gameDao.removeWinner(gameId: testGame2.id);
hasWinner = await database.gameDao.hasWinner(gameId: testGame2.id);
expect(hasWinner, false);
final removedWinner = await database.gameDao.getWinner(
gameId: testGame2.id,
);
expect(removedWinner, null);
});
test('Renaming a game works correctly', () async {
await database.gameDao.addGame(game: testGame1);
var fetchedGame = await database.gameDao.getGameById(
gameId: testGame1.id,
);
expect(fetchedGame.name, testGame1.name);
const newName = 'Updated Game Name';
await database.gameDao.updateGameName(
gameId: testGame1.id,
newName: newName,
);
fetchedGame = await database.gameDao.getGameById(gameId: testGame1.id);
expect(fetchedGame.name, newName);
});
});
}

View File

@@ -14,7 +14,8 @@ void main() {
late Player testPlayer3;
late Player testPlayer4;
late Player testPlayer5;
late Group testgroup;
late Group testGroup1;
late Group testGroup2;
late Game testgameWithGroup;
late Game testgameWithPlayers;
final fixedDate = DateTime(2025, 19, 11, 00, 11, 23);
@@ -35,15 +36,19 @@ void main() {
testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana');
testPlayer5 = Player(name: 'Eve');
testgroup = Group(
testGroup1 = Group(
name: 'Test Group',
members: [testPlayer1, testPlayer2, testPlayer3],
);
testGroup2 = Group(
name: 'Test Group',
members: [testPlayer3, testPlayer2],
);
testgameWithPlayers = Game(
name: 'Test Game with Players',
players: [testPlayer4, testPlayer5],
);
testgameWithGroup = Game(name: 'Test Game with Group', group: testgroup);
testgameWithGroup = Game(name: 'Test Game with Group', group: testGroup1);
});
});
tearDown(() async {
@@ -52,7 +57,7 @@ void main() {
group('Group-Game Tests', () {
test('Game has group works correctly', () async {
await database.gameDao.addGame(game: testgameWithPlayers);
await database.groupDao.addGroup(group: testgroup);
await database.groupDao.addGroup(group: testGroup1);
var gameHasGroup = await database.groupGameDao.gameHasGroup(
gameId: testgameWithPlayers.id,
@@ -61,8 +66,8 @@ void main() {
expect(gameHasGroup, false);
await database.groupGameDao.addGroupToGame(
testgameWithPlayers.id,
testgroup.id,
gameId: testgameWithPlayers.id,
groupId: testGroup1.id,
);
gameHasGroup = await database.groupGameDao.gameHasGroup(
@@ -74,15 +79,15 @@ void main() {
test('Adding a group to a game works correctly', () async {
await database.gameDao.addGame(game: testgameWithPlayers);
await database.groupDao.addGroup(group: testgroup);
await database.groupDao.addGroup(group: testGroup1);
await database.groupGameDao.addGroupToGame(
testgameWithPlayers.id,
testgroup.id,
gameId: testgameWithPlayers.id,
groupId: testGroup1.id,
);
var groupAdded = await database.groupGameDao.isGroupInGame(
gameId: testgameWithPlayers.id,
groupId: testgroup.id,
groupId: testGroup1.id,
);
expect(groupAdded, true);
@@ -120,14 +125,55 @@ void main() {
fail('Group should not be null');
}
expect(group.id, testgroup.id);
expect(group.name, testgroup.name);
expect(group.createdAt, testgroup.createdAt);
expect(group.members.length, testgroup.members.length);
expect(group.id, testGroup1.id);
expect(group.name, testGroup1.name);
expect(group.createdAt, testGroup1.createdAt);
expect(group.members.length, testGroup1.members.length);
for (int i = 0; i < group.members.length; i++) {
expect(group.members[i].id, testgroup.members[i].id);
expect(group.members[i].name, testgroup.members[i].name);
expect(group.members[i].createdAt, testgroup.members[i].createdAt);
expect(group.members[i].id, testGroup1.members[i].id);
expect(group.members[i].name, testGroup1.members[i].name);
expect(group.members[i].createdAt, testGroup1.members[i].createdAt);
}
});
test('Updating the group of a game works correctly', () async {
await database.gameDao.addGame(game: testgameWithGroup);
var group = await database.groupGameDao.getGroupOfGame(
gameId: testgameWithGroup.id,
);
if (group == null) {
fail('Initial group should not be null');
} else {
expect(group.id, testGroup1.id);
expect(group.name, testGroup1.name);
expect(group.createdAt, testGroup1.createdAt);
expect(group.members.length, testGroup1.members.length);
}
await database.groupDao.addGroup(group: testGroup2);
await database.groupGameDao.updateGroupOfGame(
gameId: testgameWithGroup.id,
newGroupId: testGroup2.id,
);
group = await database.groupGameDao.getGroupOfGame(
gameId: testgameWithGroup.id,
);
if (group == null) {
fail('Updated group should not be null');
} else {
expect(group.id, testGroup2.id);
expect(group.name, testGroup2.name);
expect(group.createdAt, testGroup2.createdAt);
expect(group.members.length, testGroup2.members.length);
for (int i = 0; i < group.members.length; i++) {
expect(group.members[i].id, testGroup2.members[i].id);
expect(group.members[i].name, testGroup2.members[i].name);
expect(group.members[i].createdAt, testGroup2.members[i].createdAt);
}
}
});
});

View File

@@ -81,7 +81,7 @@ void main() {
});
test('Adding and fetching multiple groups works correctly', () async {
await database.groupDao.addGroups(
await database.groupDao.addGroupsAsList(
groups: [testGroup1, testGroup2, testGroup3, testGroup4],
);

View File

@@ -136,5 +136,48 @@ void main() {
expect(players[i].createdAt, testGameOnlyPlayers.players![i].createdAt);
}
});
test('Updating the games players works coreclty', () async {
await database.gameDao.addGame(game: testGameOnlyPlayers);
final newPlayers = [testPlayer1, testPlayer2, testPlayer4];
await database.playerDao.addPlayersAsList(players: newPlayers);
// First, remove all existing players
final existingPlayers = await database.playerGameDao.getPlayersOfGame(
gameId: testGameOnlyPlayers.id,
);
if (existingPlayers == null || existingPlayers.isEmpty) {
fail('Existing players should not be null or empty');
}
await database.playerGameDao.updatePlayersFromGame(
gameId: testGameOnlyPlayers.id,
newPlayer: newPlayers,
);
final updatedPlayers = await database.playerGameDao.getPlayersOfGame(
gameId: testGameOnlyPlayers.id,
);
if (updatedPlayers == null) {
fail('Updated players should not be null');
}
expect(updatedPlayers.length, newPlayers.length);
/// Create a map of new players for easy lookup
final testPlayers = {for (var p in newPlayers) p.id: p};
/// Verify each updated player matches the new players
for (final player in updatedPlayers) {
final testPlayer = testPlayers[player.id]!;
expect(player.id, testPlayer.id);
expect(player.name, testPlayer.name);
expect(player.createdAt, testPlayer.createdAt);
}
});
});
}

View File

@@ -56,7 +56,7 @@ void main() {
});
test('Adding and fetching multiple players works correctly', () async {
await database.playerDao.addPlayers(
await database.playerDao.addPlayersAsList(
players: [testPlayer1, testPlayer2, testPlayer3, testPlayer4],
);