Merge branch 'development' into feature/3-creategameview-erstellen
All checks were successful
Pull Request Pipeline / test (pull_request) Successful in 2m8s
Pull Request Pipeline / lint (pull_request) Successful in 2m10s

This commit is contained in:
2025-12-06 16:54:30 +01:00
10 changed files with 496 additions and 273 deletions

View File

@@ -65,7 +65,6 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
/// Adds a new [Game] to the database.
/// Also adds associated players and group if they exist.
/// If a game, player, or group already exists, it will be replaced.
Future<void> addGame({required Game game}) async {
await db.transaction(() async {
await into(gameTable).insert(
@@ -101,7 +100,6 @@ class GameDao extends DatabaseAccessor<AppDatabase> with _$GameDaoMixin {
/// 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.
/// If a game, player, or group already exists, it will be replaced.
Future<void> addGamesAsList({required List<Game> games}) async {
if (games.isEmpty) return;
await db.transaction(() async {

View File

@@ -11,12 +11,14 @@ class GroupGameDao extends DatabaseAccessor<AppDatabase>
GroupGameDao(super.db);
/// Associates a group with a game by inserting a record into the
/// [GroupGameTable]. If there is already group associated to the game,
/// it will be replaced.
/// [GroupGameTable].
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,

View File

@@ -1,9 +1,17 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/presentation/views/main_menu/create_game/create_game_view.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_group_view.dart';
import 'package:game_tracker/presentation/views/main_menu/game_result_view.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/double_row_info_tile.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 {
const GameHistoryView({super.key});
@@ -13,119 +21,36 @@ class GameHistoryView extends StatefulWidget {
}
class _GameHistoryViewState extends State<GameHistoryView> {
final allGameData = [
{
'game': 'Schach',
'title': 'Abendpartie',
'players': 2,
'group': 'Familie',
'date': '01.06.2024',
},
{
'game': 'Monopoly',
'title': 'Wochenendspaß mit Gras du Saas',
'players': 4,
'group': 'Freunde',
'date': '28.05.2024',
},
{
'game': 'Catan',
'title': 'Strategieabend',
'players': 3,
'group': 'Brettspieler',
'date': '25.05.2024',
},
{
'game': 'Uno',
'title': 'Schnelle Runde',
'players': 5,
'group': 'Kollegen',
'date': '22.05.2024',
},
{
'game': 'Poker',
'title': 'Freitagspoker',
'players': 6,
'group': 'Pokerclub',
'date': '20.05.2024',
},
{
'game': 'Scrabble',
'title': 'Wortschlacht',
'players': 4,
'group': 'Familie',
'date': '18.05.2024',
},
{
'game': 'Risiko',
'title': 'Weltherrschaft',
'players': 5,
'group': 'Strategiegruppe',
'date': '15.05.2024',
},
{
'game': 'Zug um Zug',
'title': 'Zug-Abenteuer',
'players': 4,
'group': 'Reisende',
'date': '12.05.2024',
},
{
'game': 'Carcassonne',
'title': 'Plättchenlegen',
'players': 3,
'group': 'Brettspieler',
'date': '10.05.2024',
},
{
'game': 'Pandemie',
'title': 'Welt retten',
'players': 4,
'group': 'Koop-Team',
'date': '08.05.2024',
},
{
'game': 'Cluedo',
'title': 'Krimiabend',
'players': 6,
'group': 'Detektive',
'date': '05.05.2024',
},
{
'game': 'Dixit',
'title': 'Fantasiespiel',
'players': 5,
'group': 'Künstler',
'date': '02.05.2024',
},
{
'game': 'Azul',
'title': 'Plättchenmeister',
'players': 4,
'group': 'Familie',
'date': '30.04.2024',
},
{
'game': 'Splendor',
'title': 'Edelsteinhändler',
'players': 3,
'group': 'Freunde',
'date': '28.04.2024',
},
{
'game': '7 Wonders',
'title': 'Antike Reiche',
'players': 7,
'group': 'Geschichtsfreunde',
'date': '25.04.2024',
},
];
late List<Map<String, dynamic>> suggestedGameData;
late Future<List<Game>> _gameListFuture;
late final AppDatabase db;
late final List<Game> skeletonData = List.filled(
4,
Game(
name: 'Skeleton Game',
group: Group(
name: 'Skeleton Group',
members: [
Player(name: 'Player 1'),
Player(name: 'Player 2'),
Player(name: 'Player 3'),
Player(name: 'Long Name Player 4'),
Player(name: 'Player 5'),
],
),
winner: Player(name: 'Skeleton Player 1'),
players: [Player(name: 'Skeleton Player 6')],
),
);
@override
void initState() {
super.initState();
suggestedGameData = List.from(allGameData);
db = Provider.of<AppDatabase>(context, listen: false);
_gameListFuture = Future.delayed(
const Duration(milliseconds: 250),
() => db.gameDao.getAllGames(),
);
}
@override
@@ -133,65 +58,89 @@ class _GameHistoryViewState extends State<GameHistoryView> {
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
body: Stack(
alignment: Alignment.center,
children: [
Column(
children: [
Container(margin: const EdgeInsets.only(bottom: 75)),
Expanded(
child: gameHistoryListView(allGameData, suggestedGameData),
),
],
),
Container(
margin: const EdgeInsets.only(
top: 10,
bottom: 10,
left: 10,
right: 10,
),
child: SearchBar(
leading: const Icon(Icons.search),
onChanged: (value) {
if (value.isEmpty) {
setState(() {
suggestedGameData.clear();
suggestedGameData.addAll(allGameData);
});
return;
}
final suggestions = allGameData.where((currentGame) {
return currentGame['game'].toString().toLowerCase().contains(
value.toLowerCase(),
) ||
currentGame['title'].toString().toLowerCase().contains(
value.toLowerCase(),
) ||
currentGame['group'].toString().toLowerCase().contains(
value.toLowerCase(),
);
});
setState(() {
suggestedGameData.clear();
suggestedGameData.addAll(suggestions);
});
},
),
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom,
width: MediaQuery.of(context).size.width,
child: Center(
child: CustomWidthButton(
text: 'Create Game',
sizeRelativeToWidth: 0.90,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const CreateGameView(),
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: 'Info',
message: 'No games created yet',
),
);
}
final bool isLoading =
snapshot.connectionState == ConnectionState.waiting;
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(
onTap: () async {
await Navigator.push(
context,
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) =>
GameResultView(game: games[index]),
),
);
setState(() {
_gameListFuture = db.gameDao.getAllGames();
});
},
game: games[index],
);
},
),
);
},
),
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom,
child: CustomWidthButton(
text: 'Create Game',
sizeRelativeToWidth: 0.90,
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const CreateGroupView();
},
),
);
setState(() {
_gameListFuture = db.gameDao.getAllGames();
});
},
),
),
],
@@ -199,32 +148,3 @@ class _GameHistoryViewState extends State<GameHistoryView> {
);
}
}
Widget gameHistoryListView(allGameData, suggestedGameData) {
if (suggestedGameData.isEmpty && allGameData.isEmpty) {
return const TopCenteredMessage(
icon: Icons.info,
title: 'Info',
message: 'Keine Spiele erstellt',
);
} else if (suggestedGameData.isEmpty) {
return const TopCenteredMessage(
icon: Icons.search,
title: 'Info',
message: 'Kein Spiel mit den Suchparametern gefunden.',
);
}
return ListView.builder(
itemCount: suggestedGameData.length,
itemBuilder: (context, index) {
final currentGame = suggestedGameData[index];
return doubleRowInfoTile(
currentGame['game'] + ': ',
currentGame['title'],
"${currentGame['players']} Spieler",
currentGame['group'],
currentGame['date'],
);
},
);
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/data/db/database.dart';
import 'package:game_tracker/data/dto/game.dart';
import 'package:game_tracker/data/dto/player.dart';
import 'package:game_tracker/presentation/widgets/tiles/custom_radio_list_tile.dart';
import 'package:provider/provider.dart';
class GameResultView extends StatefulWidget {
final Game game;
const GameResultView({super.key, required this.game});
@override
State<GameResultView> createState() => _GameResultViewState();
}
class _GameResultViewState extends State<GameResultView> {
late final List<Player> allPlayers;
late final AppDatabase db;
Player? _selectedPlayer;
@override
void initState() {
db = Provider.of<AppDatabase>(context, listen: false);
allPlayers = getAllPlayers(widget.game);
if (widget.game.winner != null) {
_selectedPlayer = allPlayers.firstWhere(
(p) => p.id == widget.game.winner!.id,
);
}
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
backgroundColor: CustomTheme.backgroundColor,
scrolledUnderElevation: 0,
title: Text(
widget.game.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
centerTitle: true,
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 10,
),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select Winner:',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Expanded(
child: RadioGroup<Player>(
groupValue: _selectedPlayer,
onChanged: (Player? value) async {
setState(() {
_selectedPlayer = value;
});
await _handleWinnerSaving();
},
child: ListView.builder(
itemCount: allPlayers.length,
itemBuilder: (context, index) {
return CustomRadioListTile(
text: allPlayers[index].name,
value: allPlayers[index],
onContainerTap: (value) async {
setState(() {
// Check if the already selected player is the same as the newly tapped player.
if (_selectedPlayer == value) {
// If yes deselected the player by setting it to null.
_selectedPlayer = null;
} else {
// If no assign the newly tapped player to the selected player.
(_selectedPlayer = value);
}
});
await _handleWinnerSaving();
},
);
},
),
),
),
],
),
),
),
],
),
),
);
}
Future<void> _handleWinnerSaving() async {
if (_selectedPlayer == null) {
await db.gameDao.removeWinner(gameId: widget.game.id);
} else {
await db.gameDao.setWinner(
gameId: widget.game.id,
winnerId: _selectedPlayer!.id,
);
}
}
List<Player> getAllPlayers(Game game) {
if (game.group == null && game.players != null) {
return [...game.players!];
} else if (game.group != null && game.players != null) {
return [...game.players!, ...game.group!.members];
}
return [...game.group!.members];
}
}

View File

@@ -56,7 +56,7 @@ class _GroupsViewState extends State<GroupsView> {
child: TopCenteredMessage(
icon: Icons.report,
title: 'Error',
message: 'Group data couldn\'t\nbe loaded.',
message: 'Group data couldn\'t\nbe loaded',
),
);
}
@@ -66,7 +66,7 @@ class _GroupsViewState extends State<GroupsView> {
child: TopCenteredMessage(
icon: Icons.info,
title: 'Info',
message: 'No groups created yet.',
message: 'No groups created yet',
),
);
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
class CustomRadioListTile<T> extends StatelessWidget {
final String text;
final T value;
final ValueChanged<T> onContainerTap;
const CustomRadioListTile({
super.key,
required this.text,
required this.value,
required this.onContainerTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onContainerTap(value),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Radio<T>(
value: value,
activeColor: CustomTheme.primaryColor,
toggleable: true,
),
Expanded(
child: Text(
text,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
}

View File

@@ -1,71 +0,0 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
Widget doubleRowInfoTile(
String titleOneUpperLeft,
String titleTwoUpperLeft,
String titleUpperRight,
String titleLowerLeft,
String titleLowerRight,
) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: CustomTheme.secondaryColor,
),
child: Column(
children: [
Row(
children: [
Expanded(
flex: 10,
child: Text(
'$titleOneUpperLeft $titleTwoUpperLeft',
style: const TextStyle(fontSize: 20),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const Spacer(),
Expanded(
flex: 3,
child: Text(
titleUpperRight,
style: const TextStyle(fontSize: 20),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.end,
),
),
],
),
Row(
children: [
Expanded(
flex: 10,
child: Text(
titleLowerLeft,
style: const TextStyle(fontSize: 20),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const Spacer(),
Expanded(
flex: 4,
child: Text(
titleLowerRight,
style: const TextStyle(fontSize: 20),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.end,
),
),
],
),
],
),
);
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:game_tracker/core/custom_theme.dart';
import 'package:game_tracker/data/dto/game.dart';
import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart';
import 'package:intl/intl.dart';
class GameHistoryTile extends StatefulWidget {
final Game game;
final VoidCallback onTap;
const GameHistoryTile({super.key, required this.game, required this.onTap});
@override
State<GameHistoryTile> createState() => _GameHistoryTileState();
}
class _GameHistoryTileState extends State<GameHistoryTile> {
@override
Widget build(BuildContext context) {
final group = widget.game.group;
final winner = widget.game.winner;
final allPlayers = _getAllPlayers();
return GestureDetector(
onTap: widget.onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: CustomTheme.boxColor,
border: Border.all(color: CustomTheme.boxBorder),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
widget.game.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
_formatDate(widget.game.createdAt),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
const SizedBox(height: 8),
if (group != null) ...[
Row(
children: [
const Icon(Icons.group, size: 16, color: Colors.grey),
const SizedBox(width: 6),
Expanded(
child: Text(
group.name,
style: const TextStyle(fontSize: 14, color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
],
if (winner != null) ...[
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: [
const Icon(
Icons.emoji_events,
size: 20,
color: Colors.amber,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Winner: ${winner.name}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 12),
],
if (allPlayers.isNotEmpty) ...[
const Text(
'Players',
style: TextStyle(
fontSize: 13,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: allPlayers.map((player) {
return TextIconTile(text: player.name, iconEnabled: false);
}).toList(),
),
],
],
),
),
);
}
String _formatDate(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays == 0) {
return 'Today at ${DateFormat('HH:mm').format(dateTime)}';
} else if (difference.inDays == 1) {
return 'Yesterday at ${DateFormat('HH:mm').format(dateTime)}';
} else if (difference.inDays < 7) {
return '${difference.inDays} days ago';
} else {
return DateFormat('MMM d, yyyy').format(dateTime);
}
}
List<dynamic> _getAllPlayers() {
final allPlayers = <dynamic>[];
final playerIds = <String>{};
// Add players from game.players
if (widget.game.players != null) {
for (var player in widget.game.players!) {
if (!playerIds.contains(player.id)) {
allPlayers.add(player);
playerIds.add(player.id);
}
}
}
// Add players from game.group.players
if (widget.game.group?.members != null) {
for (var player in widget.game.group!.members) {
if (!playerIds.contains(player.id)) {
allPlayers.add(player);
playerIds.add(player.id);
}
}
}
return allPlayers;
}
}