Merge remote-tracking branch 'origin/development' into feature/206-Neuer-Regelsatz-Platzierung

# 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
This commit is contained in:
2026-05-09 19:18:54 +02:00
29 changed files with 1752 additions and 411 deletions

View File

@@ -197,7 +197,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
/// obsolete. For each such match, the group association is removed by setting
/// its [groupId] to null.
Future<void> deleteObsoleteMatchGroupRelations() async {
final groupMatches = await db.matchDao.getGroupMatches(
final groupMatches = await db.matchDao.getMatchesByGroup(
groupId: widget.groupToEdit!.id,
);

View File

@@ -244,7 +244,9 @@ class _GroupDetailViewState extends State<GroupDetailView> {
/// Loads statistics for this group
Future<void> _loadStatistics() async {
isLoading = true;
final groupMatches = await db.matchDao.getGroupMatches(groupId: _group.id);
final groupMatches = await db.matchDao.getMatchesByGroup(
groupId: _group.id,
);
setState(() {
totalMatches = groupMatches.length;

View File

@@ -1,19 +1,26 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/match_view/create_match/create_game_view.dart';
import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart';
import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart';
import 'package:tallee/presentation/widgets/tiles/game_tile.dart';
import 'package:tallee/presentation/widgets/top_centered_message.dart';
class ChooseGameView extends StatefulWidget {
/// A view that allows the user to choose a game from a list of available games
/// - [games]: A list of tuples containing the game name, description and ruleset
/// - [initialGameIndex]: The index of the initially selected game
/// - [games]: The list of available games
/// - [initialGameId]: The id of the initially selected game
/// - [onGamesUpdated]: Optional callback invoked when the games are updated
const ChooseGameView({
super.key,
required this.games,
required this.initialGameId,
this.onGamesUpdated,
});
/// A list of tuples containing the game name, description and ruleset
@@ -22,20 +29,37 @@ class ChooseGameView extends StatefulWidget {
/// The id of the initially selected game
final String initialGameId;
/// Optional callback invoked when the games are updated
final VoidCallback? onGamesUpdated;
@override
State<ChooseGameView> createState() => _ChooseGameViewState();
}
class _ChooseGameViewState extends State<ChooseGameView> {
late final AppDatabase db;
late List<(Game, int)> gameCounts = [];
/// Controller for the search bar
final TextEditingController searchBarController = TextEditingController();
/// Currently selected game index
late String selectedGameId;
/// Games filtered according to the current search query
late List<Game> filteredGames;
@override
void initState() {
db = Provider.of<AppDatabase>(context, listen: false);
fetchGameCounts();
selectedGameId = widget.initialGameId;
// Start with all games visible
filteredGames = List<Game>.from(widget.games);
super.initState();
}
@@ -58,6 +82,30 @@ class _ChooseGameViewState extends State<ChooseGameView> {
);
},
),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
final result = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateGameView(
onGameChanged: () {
widget.onGamesUpdated?.call();
},
),
),
);
if (result != null && result.game != null) {
setState(() {
widget.games.insert(0, result.game);
});
_refreshFromSource();
}
},
),
],
title: Text(loc.choose_game),
),
body: PopScope(
@@ -72,37 +120,101 @@ class _ChooseGameViewState extends State<ChooseGameView> {
},
child: Column(
children: [
// Search Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: CustomSearchBar(
controller: searchBarController,
hintText: loc.game_name,
onChanged: (value) {
_applySearchFilter(value);
},
),
),
const SizedBox(height: 5),
// Game list
Expanded(
child: ListView.builder(
itemCount: widget.games.length,
itemBuilder: (BuildContext context, int index) {
return TitleDescriptionListTile(
title: widget.games[index].name,
description: widget.games[index].description,
badgeText: translateRulesetToString(
widget.games[index].ruleset,
child: Visibility(
visible: filteredGames.isNotEmpty,
replacement: Visibility(
visible: widget.games.isNotEmpty,
replacement: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: loc.no_games_created_yet,
),
child: TopCenteredMessage(
icon: Icons.info,
title: loc.info,
message: AppLocalizations.of(
context,
),
isHighlighted: selectedGameId == widget.games[index].id,
onPressed: () async {
setState(() {
if (selectedGameId != widget.games[index].id) {
selectedGameId = widget.games[index].id;
} else {
selectedGameId = '';
).there_are_no_games_matching_your_search,
),
),
child: ListView.builder(
itemCount: filteredGames.length,
itemBuilder: (BuildContext context, int index) {
final game = filteredGames[index];
return GameTile(
title: game.name,
description: game.description,
badgeText: translateRulesetToString(
game.ruleset,
context,
),
badgeColor: getColorFromGameColor(game.color),
isHighlighted: selectedGameId == game.id,
onTap: () async {
setState(() {
if (selectedGameId == game.id) {
selectedGameId = '';
} else {
selectedGameId = game.id;
}
});
},
onLongPress: () async {
final result = await Navigator.push(
context,
adaptivePageRoute(
builder: (context) => CreateGameView(
gameToEdit: game,
matchCount: getMatchCount(game),
onGameChanged: () {
widget.onGamesUpdated?.call();
},
),
),
);
if (result != null && result.game != null) {
// Find the index in the original list to mutate
final originalIndex = widget.games.indexWhere(
(g) => g.id == game.id,
);
if (originalIndex == -1) {
return;
}
if (result.delete) {
setState(() {
// deselect the game
if (selectedGameId == game.id) {
selectedGameId = '';
}
widget.games.removeAt(originalIndex);
widget.onGamesUpdated?.call();
});
} else {
setState(() {
widget.games[originalIndex] = result.game;
});
}
_refreshFromSource();
}
});
},
);
},
},
);
},
),
),
),
],
@@ -110,4 +222,39 @@ class _ChooseGameViewState extends State<ChooseGameView> {
),
);
}
/// Applies the search filter to the games list based on [query].
void _applySearchFilter(String query) {
final q = query.toLowerCase().trim();
if (q.isEmpty) {
setState(() {
filteredGames = List<Game>.from(widget.games);
});
return;
}
setState(() {
filteredGames = widget.games.where((game) {
final name = game.name.toLowerCase();
final description = game.description.toLowerCase();
return name.contains(q) || description.contains(q);
}).toList();
});
}
/// Re-applies the current filter after the underlying games list changed.
void _refreshFromSource() {
_applySearchFilter(searchBarController.text);
}
Future<void> fetchGameCounts() async {
gameCounts = await db.gameDao.getGameUsage();
}
// Returns the number of matches that use the given [game].
int getMatchCount(Game game) {
return gameCounts
.firstWhere((gc) => gc.$1.id == game.id, orElse: () => (game, 0))
.$2;
}
}

View File

@@ -0,0 +1,520 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_popup/flutter_popup.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/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/buttons/custom_width_button.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/choose_tile.dart';
/// A stateful widget for creating or editing a game.
/// - [gameToEdit] An optional game to prefill the fields
/// - [onGameChanged] Callback to invoke when the game is created or edited
class CreateGameView extends StatefulWidget {
const CreateGameView({
super.key,
required this.onGameChanged,
this.gameToEdit,
this.matchCount = 0,
});
/// Callback to invoke when the game is created or edited
final VoidCallback onGameChanged;
/// An optional game to prefill the fields
final Game? gameToEdit;
final int matchCount;
@override
State<CreateGameView> createState() => _CreateGameViewState();
}
class _CreateGameViewState extends State<CreateGameView> {
/// GlobalKey for ScaffoldMessenger to show snackbars
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
late final AppDatabase db;
late List<(Ruleset, String)> _rulesets;
Ruleset? selectedRuleset = Ruleset.singleWinner;
late List<(GameColor, String)> _colors;
GameColor? selectedColor = GameColor.orange;
/// Controller for the game name input field.
final _gameNameController = TextEditingController();
/// Controller for the game description input field.
final _descriptionController = TextEditingController();
/// The ID of the currently selected group.
late String selectedGroupId;
/// A controller for the search bar input field.
final TextEditingController controller = TextEditingController();
/// A list of groups filtered based on the search query.
late final List<Group> filteredGroups;
@override
void initState() {
super.initState();
db = Provider.of<AppDatabase>(context, listen: false);
_gameNameController.addListener(() => setState(() {}));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_rulesets = [
(
Ruleset.singleWinner,
translateRulesetToString(Ruleset.singleWinner, context),
),
(
Ruleset.singleLoser,
translateRulesetToString(Ruleset.singleLoser, context),
),
(
Ruleset.highestScore,
translateRulesetToString(Ruleset.highestScore, context),
),
(
Ruleset.lowestScore,
translateRulesetToString(Ruleset.lowestScore, context),
),
(
Ruleset.multipleWinners,
translateRulesetToString(Ruleset.multipleWinners, context),
),
];
_colors = [
(GameColor.green, translateGameColorToString(GameColor.green, context)),
(GameColor.teal, translateGameColorToString(GameColor.teal, context)),
(GameColor.blue, translateGameColorToString(GameColor.blue, context)),
(GameColor.purple, translateGameColorToString(GameColor.purple, context)),
(GameColor.pink, translateGameColorToString(GameColor.pink, context)),
(GameColor.red, translateGameColorToString(GameColor.red, context)),
(GameColor.orange, translateGameColorToString(GameColor.orange, context)),
(GameColor.yellow, translateGameColorToString(GameColor.yellow, context)),
];
if (widget.gameToEdit != null) {
_gameNameController.text = widget.gameToEdit!.name;
_descriptionController.text = widget.gameToEdit!.description;
selectedRuleset = widget.gameToEdit!.ruleset;
selectedColor = widget.gameToEdit!.color;
selectedRuleset = widget.gameToEdit!.ruleset;
}
}
@override
void dispose() {
_gameNameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var loc = AppLocalizations.of(context);
final isEditing = widget.gameToEdit != null;
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
title: Text(isEditing ? loc.edit_game : loc.create_game),
actions: [
if (isEditMode())
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
if (!context.mounted) return;
// Build the dialog content based on match count
final String dialogContent = widget.matchCount > 0
? loc.delete_game_with_matches_warning(widget.matchCount)
: loc.this_cannot_be_undone;
showDialog<bool>(
context: context,
builder: (context) => CustomAlertDialog(
title: loc.delete_game,
content: Text(
dialogContent,
style: const TextStyle(fontSize: 15),
),
actions: [
CustomDialogAction(
isDestructive: true,
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 == true && context.mounted) {
// Delete assocaited matches
if (widget.matchCount > 0) {
await db.matchDao.deleteMatchesByGame(
gameId: widget.gameToEdit!.id,
);
}
// Delete the targetted game
bool success = await db.gameDao.deleteGame(
gameId: widget.gameToEdit!.id,
);
if (!context.mounted) return;
if (success) {
widget.onGameChanged.call();
Navigator.of(
context,
).pop((game: widget.gameToEdit, delete: true));
} else {
if (!mounted) return;
showSnackbar(message: loc.error_deleting_game);
}
}
});
},
),
],
),
body: SafeArea(
child: Column(
children: [
// Game name input field
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
controller: _gameNameController,
maxLength: Constants.MAX_MATCH_NAME_LENGTH,
hintText: loc.game_name,
),
),
// Choose ruleset tile
if (!isEditMode())
ChooseTile(title: loc.ruleset, trailing: getColorDropdown(loc)),
// Choose color tile
ChooseTile(title: loc.color, trailing: getRulesetDropdown(loc)),
// Description input field
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
controller: _descriptionController,
hintText: loc.description,
minLines: 6,
maxLines: 6,
maxLength: Constants.MAX_GAME_DESCRIPTION_LENGTH,
showCounterText: true,
),
),
const Spacer(),
// Create/Edit game button
Padding(
padding: const EdgeInsets.all(12.0),
child: CustomWidthButton(
text: isEditing ? loc.edit_game : loc.create_game,
sizeRelativeToWidth: 1,
buttonType: ButtonType.primary,
onPressed:
_gameNameController.text.trim().isNotEmpty &&
selectedRuleset != null &&
selectedColor != null
? () async {
Game newGame = Game(
name: _gameNameController.text.trim(),
description: _descriptionController.text.trim(),
ruleset: selectedRuleset!,
color: selectedColor!,
);
if (isEditing) {
await handleGameUpdate(newGame);
} else {
await handleGameCreation(newGame);
}
widget.onGameChanged.call();
if (context.mounted) {
Navigator.of(
context,
).pop((game: newGame, delete: false));
}
}
: null,
),
),
],
),
),
),
);
}
/// Handles updating an existing game in the database.
///
/// [newGame] The updated game object.
Future<void> handleGameUpdate(Game newGame) async {
final oldGame = widget.gameToEdit!;
if (oldGame.name != newGame.name) {
await db.gameDao.updateGameName(gameId: oldGame.id, name: newGame.name);
}
if (oldGame.description != newGame.description) {
await db.gameDao.updateGameDescription(
gameId: oldGame.id,
description: newGame.description,
);
}
if (oldGame.ruleset != newGame.ruleset) {
await db.gameDao.updateGameRuleset(
gameId: oldGame.id,
ruleset: newGame.ruleset,
);
}
if (oldGame.color != newGame.color) {
await db.gameDao.updateGameColor(
gameId: oldGame.id,
color: newGame.color,
);
}
if (oldGame.icon != newGame.icon) {
await db.gameDao.updateGameIcon(gameId: oldGame.id, icon: newGame.icon);
}
}
/// Handles creating a new game in the database.
///
/// [newGame] The game object to be created.
Future<void> handleGameCreation(Game newGame) async {
await db.gameDao.addGame(game: newGame);
}
/// Displays a snackbar with the given message and optional action.
///
/// [message] The message to display in the snackbar.
void showSnackbar({required String message}) {
final messenger = _scaffoldMessengerKey.currentState;
if (messenger != null) {
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(message, style: const TextStyle(color: Colors.white)),
backgroundColor: CustomTheme.boxColor,
),
);
}
}
bool isEditMode() {
return widget.gameToEdit != null;
}
Widget getRulesetDropdown(AppLocalizations loc) {
return CustomPopup(
showArrow: true,
arrowColor: CustomTheme.boxBorderColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
barrierColor: Colors.transparent,
contentDecoration: CustomTheme.standardBoxDecoration,
content: StatefulBuilder(
builder: (context, setPopupState) => SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
_rulesets.length,
(index) => GestureDetector(
onTap: () {
setState(() {
selectedRuleset = _rulesets[index].$1;
});
setPopupState(() {});
},
child: Column(
children: [
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
color: selectedRuleset == _rulesets[index].$1
? CustomTheme.textColor.withAlpha(20)
: Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
child: Row(
spacing: 8,
children: [
Icon(getRulesetIcon(_rulesets[index].$1), size: 16),
Text(
_rulesets[index].$2,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 15,
),
),
],
),
),
),
if (index < _rulesets.length - 1)
const Divider(indent: 15, endIndent: 15),
],
),
),
),
),
),
),
child: Row(
spacing: 8,
children: [
Icon(getRulesetIcon(selectedRuleset!), size: 16),
Padding(
padding: const EdgeInsets.only(right: 5),
child: Text(
translateRulesetToString(selectedRuleset!, context),
textAlign: TextAlign.right,
),
),
Transform.rotate(
angle: pi / 2,
child: const Icon(Icons.arrow_forward_ios, size: 16),
),
],
),
);
}
Widget getColorDropdown(AppLocalizations loc) {
return CustomPopup(
showArrow: true,
arrowColor: CustomTheme.boxBorderColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
barrierColor: Colors.transparent,
contentDecoration: CustomTheme.standardBoxDecoration,
content: StatefulBuilder(
builder: (context, setPopupState) => SizedBox(
width: 150,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
_colors.length,
(index) => GestureDetector(
onTap: () {
setState(() {
selectedColor = _colors[index].$1;
});
setPopupState(() {});
},
child: Column(
children: [
// Selected Highlighting
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
color: selectedColor == _colors[index].$1
? CustomTheme.textColor.withAlpha(20)
: Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
spacing: 8,
children: selectedColor == null
? [Text(loc.none)]
: [
Container(
width: 16,
height: 16,
margin: const EdgeInsets.only(left: 12),
decoration: BoxDecoration(
color: getColorFromGameColor(
_colors[index].$1,
),
shape: BoxShape.circle,
),
),
Text(
_colors[index].$2,
style: const TextStyle(
color: CustomTheme.textColor,
fontSize: 15,
),
),
],
),
),
),
if (index < _colors.length - 1)
const Divider(indent: 15, endIndent: 15),
],
),
),
),
),
),
),
child: Row(
spacing: 8,
children: [
// Selected Color
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: getColorFromGameColor(selectedColor!),
shape: BoxShape.circle,
),
),
Padding(
padding: const EdgeInsets.only(right: 5),
child: Text(translateGameColorToString(selectedColor!, context)),
),
Transform.rotate(
angle: pi / 2,
child: const Icon(Icons.arrow_forward_ios, size: 16),
),
],
),
);
}
}

View File

@@ -28,10 +28,13 @@ class CreateMatchView extends StatefulWidget {
this.onWinnerChanged,
this.matchToEdit,
this.onMatchUpdated,
this.onMatchesUpdated,
});
final VoidCallback? onWinnerChanged;
final VoidCallback? onMatchesUpdated;
final void Function(Match)? onMatchUpdated;
/// An optional match to prefill the fields for editing.
@@ -115,6 +118,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Match name input field.
Container(
margin: CustomTheme.tileMargin,
child: TextInputField(
@@ -123,34 +127,40 @@ class _CreateMatchViewState extends State<CreateMatchView> {
maxLength: Constants.MAX_MATCH_NAME_LENGTH,
),
),
ChooseTile(
title: loc.game,
trailingText: selectedGame == null
? loc.none_group
: selectedGame!.name,
onPressed: () async {
selectedGame = await Navigator.of(context).push(
adaptivePageRoute(
builder: (context) => ChooseGameView(
games: gamesList,
initialGameId: selectedGame?.id ?? '',
// Game selection tile.
if (!isEditMode())
ChooseTile(
title: loc.game,
trailing: selectedGame == null
? Text(loc.none_group)
: Text(selectedGame!.name),
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;
}
});
},
),
);
setState(() {
if (selectedGame != null) {
hintText = selectedGame!.name;
} else {
hintText = loc.match_name;
}
});
},
),
// Group selection tile.
ChooseTile(
title: loc.group,
trailingText: selectedGroup == null
? loc.none_group
: selectedGroup!.name,
trailing: selectedGroup == null
? Text(loc.none_group)
: Text(selectedGroup!.name),
onPressed: () async {
// Remove all players from the previously selected group from
// the selected players list, in case the user deselects the
@@ -181,6 +191,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
});
},
),
// Player selection widget.
Expanded(
child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'),
@@ -193,6 +205,8 @@ class _CreateMatchViewState extends State<CreateMatchView> {
},
),
),
// Create or save button.
CustomWidthButton(
text: buttonText,
sizeRelativeToWidth: 0.95,
@@ -218,16 +232,16 @@ class _CreateMatchViewState extends State<CreateMatchView> {
///
/// Returns `true` if:
/// - A ruleset is selected AND
/// - Either a group is selected OR at least 2 players are selected
/// - Either a group is selected OR at least 2 players are selected.
bool _enableCreateGameButton() {
return (selectedGroup != null ||
(selectedPlayers.length > 1) && selectedGame != null);
}
// If a match was provided to the view, it updates the match in the database
// and navigates back to the previous screen.
// If no match was provided, it creates a new match in the database and
// navigates to the MatchResultView for the newly created match.
/// Handles navigation when the create or save button is pressed.
///
/// If a match is being edited, updates the match in the database.
/// Otherwise, creates a new match and navigates to the MatchResultView.
void buttonNavigation(BuildContext context) async {
if (isEditMode()) {
await updateMatch();
@@ -252,8 +266,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
}
}
/// Updates attributes of the existing match in the database based on the
/// changes made in the edit view.
/// Updates the existing match in the database.
Future<void> updateMatch() async {
final updatedMatch = Match(
id: widget.matchToEdit!.id,
@@ -262,7 +275,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
: _matchNameController.text.trim(),
group: selectedGroup,
players: selectedPlayers,
game: widget.matchToEdit!.game,
game: selectedGame!,
createdAt: widget.matchToEdit!.createdAt,
endedAt: widget.matchToEdit!.endedAt,
notes: widget.matchToEdit!.notes,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:fluttericon/rpg_awesome_icons.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart';
@@ -14,6 +15,7 @@ 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/game_label.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -102,6 +104,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
bottom: 100,
),
children: [
// Controller Icon
const Center(
child: ColoredIconContainer(
icon: Icons.sports_esports,
@@ -110,6 +113,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
),
const SizedBox(height: 10),
// Match Name
Text(
match.name,
style: const TextStyle(
@@ -120,6 +125,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
textAlign: TextAlign.center,
),
const SizedBox(height: 5),
// Creation Date
Text(
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(match.createdAt)}',
style: const TextStyle(
@@ -129,6 +136,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
// Group Name
if (match.group != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -143,6 +152,8 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
const SizedBox(height: 20),
],
// Players
InfoTile(
title: loc.players,
icon: Icons.people,
@@ -162,6 +173,30 @@ class _MatchDetailViewState extends State<MatchDetailView> {
),
),
const SizedBox(height: 15),
// Game
InfoTile(
title: loc.game,
icon: RpgAwesome.clovers_card,
horizontalAlignment: CrossAxisAlignment.start,
content: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: GameLabel(
title: match.game.name,
description: translateRulesetToString(
match.game.ruleset,
context,
),
color: match.game.color,
),
),
),
const SizedBox(height: 15),
// Results
InfoTile(
title: loc.results,
icon: Icons.emoji_events,

View File

@@ -126,8 +126,10 @@ class _MatchViewState extends State<MatchView> {
Navigator.push(
context,
adaptivePageRoute(
builder: (context) =>
CreateMatchView(onWinnerChanged: loadMatches),
builder: (context) => CreateMatchView(
onWinnerChanged: loadMatches,
onMatchesUpdated: loadMatches,
),
),
);
},

View File

@@ -55,6 +55,7 @@ const allDependencies = <Package>[
_flutter_lints,
_flutter_localizations,
_flutter_plugin_android_lifecycle,
_flutter_popup,
_flutter_test,
_flutter_web_plugins,
_fluttericon,
@@ -168,6 +169,7 @@ const dependencies = <Package>[
_file_saver,
_flutter,
_flutter_localizations,
_flutter_popup,
_fluttericon,
_font_awesome_flutter,
_intl,
@@ -2628,6 +2630,41 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
);
/// flutter_popup 3.3.9
const _flutter_popup = Package(
name: 'flutter_popup',
description: 'The flutter_popup package is a versatile tool for creating customizable popups in Flutter apps. Its highlight feature effectively guides user attention to specific areas',
homepage: 'https://github.com/herowws/flutter_popup',
authors: [],
version: '3.3.9',
spdxIdentifiers: ['MIT'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('flutter')],
devDependencies: [PackageRef('flutter_lints'), PackageRef('flutter_test')],
license: '''MIT License
Copyright (c) 2023 mopriestt
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.''',
);
/// flutter_test null
const _flutter_test = Package(
name: 'flutter_test',
@@ -37676,16 +37713,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.''',
);
/// tallee 0.0.27+261
/// tallee 0.0.28+262
const _tallee = Package(
name: 'tallee',
description: 'Tracking App for Card Games',
authors: [],
version: '0.0.27+261',
version: '0.0.28+262',
spdxIdentifiers: ['LGPL-3.0'],
isMarkdown: false,
isSdk: false,
dependencies: [PackageRef('clock'), PackageRef('collection'), PackageRef('cupertino_icons'), PackageRef('drift'), PackageRef('drift_flutter'), PackageRef('file_picker'), PackageRef('file_saver'), PackageRef('flutter'), PackageRef('flutter_localizations'), PackageRef('fluttericon'), PackageRef('font_awesome_flutter'), PackageRef('intl'), PackageRef('json_schema'), PackageRef('package_info_plus'), PackageRef('path_provider'), PackageRef('provider'), PackageRef('skeletonizer'), PackageRef('url_launcher'), PackageRef('uuid')],
dependencies: [PackageRef('clock'), PackageRef('collection'), PackageRef('cupertino_icons'), PackageRef('drift'), PackageRef('drift_flutter'), PackageRef('file_picker'), PackageRef('file_saver'), PackageRef('flutter'), PackageRef('flutter_localizations'), PackageRef('flutter_popup'), PackageRef('fluttericon'), PackageRef('font_awesome_flutter'), PackageRef('intl'), PackageRef('json_schema'), PackageRef('package_info_plus'), PackageRef('path_provider'), PackageRef('provider'), PackageRef('skeletonizer'), PackageRef('url_launcher'), PackageRef('uuid')],
devDependencies: [PackageRef('flutter_test'), PackageRef('build_runner'), PackageRef('dart_pubspec_licenses'), PackageRef('drift_dev'), PackageRef('flutter_lints')],
license: '''GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007

View File

@@ -14,6 +14,7 @@ class AnimatedDialogButton extends StatefulWidget {
required this.onPressed,
this.buttonConstraints,
this.buttonType = ButtonType.primary,
this.isDescructive = false,
});
final String buttonText;
@@ -24,6 +25,8 @@ class AnimatedDialogButton extends StatefulWidget {
final ButtonType buttonType;
final bool isDescructive;
@override
State<AnimatedDialogButton> createState() => _AnimatedDialogButtonState();
}
@@ -33,28 +36,8 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
@override
Widget build(BuildContext context) {
final textStyling = TextStyle(
color: widget.buttonType == ButtonType.primary
? Colors.black
: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
);
final buttonDecoration = widget.buttonType == ButtonType.primary
// Primary
? BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
)
: widget.buttonType == ButtonType.secondary
// Secondary
? BoxDecoration(
border: BoxBorder.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(12),
)
// Tertiary
: const BoxDecoration();
final textStyling = _getTextStyling();
final buttonDecoration = _getButtonDecoration();
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
@@ -84,4 +67,42 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
),
);
}
TextStyle _getTextStyling() {
late Color textColor;
if (widget.buttonType == ButtonType.primary) {
textColor = widget.isDescructive ? Colors.white : Colors.black;
} else if (widget.buttonType == ButtonType.secondary) {
textColor = widget.isDescructive ? Colors.red : Colors.white;
} else {
textColor = widget.isDescructive ? Colors.red : Colors.white;
}
return TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
);
}
BoxDecoration _getButtonDecoration() {
if (widget.buttonType == ButtonType.primary) {
// Primary
return BoxDecoration(
color: widget.isDescructive ? Colors.red : Colors.white,
borderRadius: BorderRadius.circular(12),
);
} else if (widget.buttonType == ButtonType.secondary) {
// Secondary
return BoxDecoration(
border: BoxBorder.all(
color: widget.isDescructive ? Colors.red : Colors.white,
width: 2,
),
borderRadius: BorderRadius.circular(12),
);
}
// Tertiary
return const BoxDecoration();
}
}

View File

@@ -12,6 +12,7 @@ class CustomDialogAction extends StatelessWidget {
required this.onPressed,
required this.text,
this.buttonType = ButtonType.primary,
this.isDestructive = false,
});
final String text;
@@ -20,12 +21,15 @@ class CustomDialogAction extends StatelessWidget {
final VoidCallback onPressed;
final bool isDestructive;
@override
Widget build(BuildContext context) {
return AnimatedDialogButton(
onPressed: onPressed,
buttonText: text,
buttonType: buttonType,
isDescructive: isDestructive,
buttonConstraints: const BoxConstraints(minWidth: 300),
);
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/enums.dart';
class GameLabel extends StatelessWidget {
const GameLabel({
super.key,
required this.title,
required this.description,
required this.color,
});
final String title;
final String description;
final GameColor color;
@override
Widget build(BuildContext context) {
final backgroundColor = getColorFromGameColor(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,
),
),
),
// Description
Container(
decoration: BoxDecoration(
color: backgroundColor.withAlpha(140),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
description,
style: TextStyle(
fontSize: 12,
color: fontColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@@ -8,12 +8,18 @@ class TextInputField extends StatelessWidget {
/// - [onChanged]: Optional callback invoked when the text in the field changes.
/// - [hintText]: The hint text displayed in the text input field when it is empty
/// - [maxLength]: Optional parameter for maximum length of the input text.
/// - [maxLines]: The maximum number of lines for the text input field. Defaults to 1.
/// - [minLines]: The minimum number of lines for the text input field. Defaults to 1.
/// - [showCounterText]: Whether to show the counter text in the text input field. Defaults to false.
const TextInputField({
super.key,
required this.controller,
required this.hintText,
this.onChanged,
this.maxLength,
this.maxLines = 1,
this.minLines = 1,
this.showCounterText = false,
});
/// The controller for the text input field.
@@ -28,6 +34,15 @@ class TextInputField extends StatelessWidget {
/// Optional parameter for maximum length of the input text.
final int? maxLength;
/// The maximum number of lines for the text input field.
final int? maxLines;
/// The minimum number of lines for the text input field.
final int? minLines;
/// Whether to show the counter text in the text input field.
final bool showCounterText;
@override
Widget build(BuildContext context) {
return TextField(
@@ -35,13 +50,15 @@ class TextInputField extends StatelessWidget {
onChanged: onChanged,
maxLength: maxLength,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
maxLines: maxLines,
minLines: minLines,
decoration: InputDecoration(
filled: true,
fillColor: CustomTheme.boxColor,
hintText: hintText,
hintStyle: const TextStyle(fontSize: 18),
// Hides the character counter
counterText: '',
counterText: showCounterText ? null : '',
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: CustomTheme.boxBorderColor),

View File

@@ -4,12 +4,12 @@ import 'package:tallee/core/custom_theme.dart';
class ChooseTile extends StatefulWidget {
/// A tile widget that allows users to choose an option by tapping on it.
/// - [title]: The title text displayed on the tile.
/// - [trailingText]: Optional trailing text displayed on the tile.
/// - [trailing]: Optional trailing text displayed on the tile.
/// - [onPressed]: The callback invoked when the tile is tapped.
const ChooseTile({
super.key,
required this.title,
this.trailingText,
this.trailing,
this.onPressed,
});
@@ -20,7 +20,7 @@ class ChooseTile extends StatefulWidget {
final VoidCallback? onPressed;
/// Optional trailing text displayed on the tile.
final String? trailingText;
final Widget? trailing;
@override
State<ChooseTile> createState() => _ChooseTileState();
@@ -42,9 +42,11 @@ class _ChooseTileState extends State<ChooseTile> {
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),
if (widget.trailing != null) widget.trailing!,
if (widget.onPressed != null) ...[
const SizedBox(width: 10),
const Icon(Icons.arrow_forward_ios, size: 16),
],
],
),
),

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
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.
/// - [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.
/// - [isHighlighted]: A boolean to determine if the tile should be highlighted.
/// - [badgeText]: Optional text to display in a badge on the right side of the title.
/// - [badgeColor]: Optional color for the badge background.
const GameTile({
super.key,
required this.title,
required this.description,
this.onTap,
this.onLongPress,
this.isHighlighted = false,
this.badgeText,
this.badgeColor,
});
/// The title text displayed on the tile.
final String title;
/// 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
Widget build(BuildContext context) {
final badgeTextColor = badgeColor != null
? (badgeColor!.computeLuminance() > 0.5 ? Colors.black : Colors.white)
: Colors.white;
final gameColor = badgeColor ?? getColorFromGameColor(GameColor.orange);
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: AnimatedContainer(
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,
),
),
duration: const Duration(milliseconds: 200),
child: Stack(
children: [
// Gradient overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
gameColor.withValues(alpha: 0.08),
gameColor.withValues(alpha: 0.02),
Colors.transparent,
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Title
Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
// Badge
if (badgeText != null) ...[
const SizedBox(height: 5),
Container(
constraints: const BoxConstraints(maxWidth: 250),
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 6,
),
decoration: BoxDecoration(
color: gameColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
badgeText!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: TextStyle(
color: badgeTextColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
// Description
if (description.isNotEmpty) ...[
const SizedBox(height: 10),
Text(description, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 2.5),
],
],
),
),
],
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ 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/game_label.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class MatchTile extends StatefulWidget {
@@ -116,56 +117,13 @@ class _MatchTileState extends State<MatchTile> {
// Game + Ruleset Badge
if (!widget.compact)
IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Game
Container(
decoration: BoxDecoration(
color: CustomTheme.primaryColor.withAlpha(230),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Text(
match.game.name,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
// Ruleset
Container(
decoration: BoxDecoration(
color: CustomTheme.primaryColor.withAlpha(140),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Text(
translateRulesetToString(match.game.ruleset, context),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
GameLabel(
title: match.game.name,
description: translateRulesetToString(
match.game.ruleset,
context,
),
color: match.game.color,
),
const SizedBox(height: 12),

View File

@@ -2,21 +2,17 @@ import 'package:flutter/material.dart';
import 'package:tallee/core/custom_theme.dart';
class TitleDescriptionListTile extends StatelessWidget {
/// A list tile widget that displays a title and description, with optional highlighting and badge.
/// A list tile widget that displays a title and description
/// - [title]: The title text displayed on the tile.
/// - [description]: The description text displayed below the title.
/// - [onPressed]: The callback invoked when the tile is tapped.
/// - [onTap]: The callback invoked when the tile is tapped.
/// - [isHighlighted]: A boolean to determine if the tile should be highlighted.
/// - [badgeText]: Optional text to display in a badge on the right side of the title.
/// - [badgeColor]: Optional color for the badge background.
const TitleDescriptionListTile({
super.key,
required this.title,
required this.description,
this.onPressed,
this.onTap,
this.isHighlighted = false,
this.badgeText,
this.badgeColor,
});
/// The title text displayed on the tile.
@@ -26,21 +22,15 @@ class TitleDescriptionListTile extends StatelessWidget {
final String description;
/// The callback invoked when the tile is tapped.
final VoidCallback? onPressed;
final VoidCallback? onTap;
/// 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
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
onTap: onTap,
child: AnimatedContainer(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
@@ -51,53 +41,26 @@ class TitleDescriptionListTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 230,
child: Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
// Title
SizedBox(
width: 230,
child: Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
if (badgeText != null) ...[
const Spacer(),
Container(
constraints: const BoxConstraints(maxWidth: 115),
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 6,
),
decoration: BoxDecoration(
color: badgeColor ?? CustomTheme.primaryColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
badgeText!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
// Description
if (description.isNotEmpty) ...[
const SizedBox(height: 5),
const SizedBox(height: 10),
Text(description, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 2.5),
],