Merge pull request #79 from flixcoo/enhance/34-improvement-for-visual-hierachy

Improvement for visual hierarchy
This commit is contained in:
2025-07-20 21:09:31 +02:00
committed by GitHub
11 changed files with 281 additions and 182 deletions

View File

@@ -1,5 +1,6 @@
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class GameManager extends ChangeNotifier { class GameManager extends ChangeNotifier {
@@ -10,17 +11,25 @@ class GameManager extends ChangeNotifier {
/// sorts the list in descending order based on the creation date, and notifies listeners of the change. /// sorts the list in descending order based on the creation date, and notifies listeners of the change.
/// It also saves the updated game sessions to local storage. /// It also saves the updated game sessions to local storage.
/// Returns the index of the newly added session in the sorted list. /// Returns the index of the newly added session in the sorted list.
Future<int> addGameSession(GameSession session) async { int addGameSession(GameSession session) {
session.addListener(() { session.addListener(() {
notifyListeners(); // Propagate session changes notifyListeners(); // Propagate session changes
}); });
gameList.add(session); gameList.add(session);
gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt)); gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt));
notifyListeners(); notifyListeners();
await LocalStorageService.saveGameSessions(); LocalStorageService.saveGameSessions();
return gameList.indexOf(session); return gameList.indexOf(session);
} }
/// Retrieves a game session by its id.
/// Takes a String [id] as input. It searches the `gameList` for a session
/// with a matching id and returns it if found.
/// If no session is found, it returns null.
GameSession? getGameSessionById(String id) {
return gameList.firstWhereOrNull((session) => session.id == id);
}
/// Removes a game session from the list and sorts it by creation date. /// Removes a game session from the list and sorts it by creation date.
/// Takes a [index] as input. It then removes the session at the specified index from the `gameList`, /// Takes a [index] as input. It then removes the session at the specified index from the `gameList`,
/// sorts the list in descending order based on the creation date, and notifies listeners of the change. /// sorts the list in descending order based on the creation date, and notifies listeners of the change.

View File

@@ -13,7 +13,7 @@ import 'package:uuid/uuid.dart';
/// [isGameFinished] is a boolean indicating if the game has ended yet. /// [isGameFinished] is a boolean indicating if the game has ended yet.
/// [winner] is the name of the player who won the game. /// [winner] is the name of the player who won the game.
class GameSession extends ChangeNotifier { class GameSession extends ChangeNotifier {
late String id; final String id;
final DateTime createdAt; final DateTime createdAt;
final String gameTitle; final String gameTitle;
final List<String> players; final List<String> players;
@@ -27,6 +27,7 @@ class GameSession extends ChangeNotifier {
List<Round> roundList = []; List<Round> roundList = [];
GameSession({ GameSession({
required this.id,
required this.createdAt, required this.createdAt,
required this.gameTitle, required this.gameTitle,
required this.players, required this.players,
@@ -35,8 +36,6 @@ class GameSession extends ChangeNotifier {
required this.isPointsLimitEnabled, required this.isPointsLimitEnabled,
}) { }) {
playerScores = List.filled(players.length, 0); playerScores = List.filled(players.length, 0);
var uuid = const Uuid();
id = uuid.v1();
} }
@override @override

View File

@@ -55,7 +55,7 @@
"min_players_title": "Zu wenig Spieler:innen", "min_players_title": "Zu wenig Spieler:innen",
"min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden", "min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden",
"no_name_title": "Kein Name", "no_name_title": "Kein Name",
"no_name_message": "Jeder Spieler muss einen Namen haben.", "no_name_message": "Jede:r Spieler:in muss einen Namen haben.",
"select_game_mode": "Spielmodus auswählen", "select_game_mode": "Spielmodus auswählen",
"no_mode_selected": "Wähle einen Spielmodus", "no_mode_selected": "Wähle einen Spielmodus",

View File

@@ -365,7 +365,7 @@ abstract class AppLocalizations {
/// No description provided for @no_name_message. /// No description provided for @no_name_message.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
/// **'Jeder Spieler muss einen Namen haben.'** /// **'Jede:r Spieler:in muss einen Namen haben.'**
String get no_name_message; String get no_name_message;
/// No description provided for @select_game_mode. /// No description provided for @select_game_mode.

View File

@@ -149,7 +149,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get no_name_title => 'Kein Name'; String get no_name_title => 'Kein Name';
@override @override
String get no_name_message => 'Jeder Spieler muss einen Namen haben.'; String get no_name_message => 'Jede:r Spieler:in muss einen Namen haben.';
@override @override
String get select_game_mode => 'Spielmodus auswählen'; String get select_game_mode => 'Spielmodus auswählen';

View File

@@ -4,8 +4,12 @@ import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/active_game_view.dart'; import 'package:cabo_counter/presentation/views/active_game_view.dart';
import 'package:cabo_counter/presentation/views/mode_selection_view.dart'; import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/presentation/widgets/custom_button.dart';
import 'package:cabo_counter/services/config_service.dart'; import 'package:cabo_counter/services/config_service.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:uuid/uuid.dart';
enum CreateStatus { enum CreateStatus {
noGameTitle, noGameTitle,
@@ -42,6 +46,9 @@ class _CreateGameViewState extends State<CreateGameView> {
/// Maximum number of players allowed in the game. /// Maximum number of players allowed in the game.
final int maxPlayers = 5; final int maxPlayers = 5;
/// Factor to adjust the view length when the keyboard is visible.
final double keyboardHeightAdjustmentFactor = 0.75;
/// Variable to hold the selected game mode. /// Variable to hold the selected game mode.
late GameMode gameMode; late GameMode gameMode;
@@ -64,120 +71,92 @@ class _CreateGameViewState extends State<CreateGameView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CupertinoPageScaffold( return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
previousPageTitle: AppLocalizations.of(context).overview, previousPageTitle: AppLocalizations.of(context).overview,
middle: Text(AppLocalizations.of(context).new_game), middle: Text(AppLocalizations.of(context).new_game),
), ),
child: SafeArea( child: SafeArea(
child: Center( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text( child: Text(
AppLocalizations.of(context).game, AppLocalizations.of(context).game,
style: CustomTheme.rowTitle, style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoTextField(
decoration: const BoxDecoration(),
maxLength: 16,
prefix: Text(AppLocalizations.of(context).name),
textAlign: TextAlign.right,
placeholder: AppLocalizations.of(context).game_title,
controller: _gameTitleTextController,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoTextField(
decoration: const BoxDecoration(),
readOnly: true,
prefix: Text(AppLocalizations.of(context).mode),
suffix: Row(
children: [
Text(
gameMode == GameMode.none
? AppLocalizations.of(context).no_mode_selected
: (gameMode == GameMode.pointLimit
? '${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited),
),
const SizedBox(width: 3),
const CupertinoListTileChevron(),
],
), ),
onTap: () async {
final selectedMode = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: ConfigService.getPointLimit(),
showDeselection: false,
),
),
);
setState(() {
gameMode = selectedMode ?? gameMode;
});
},
), ),
), Padding(
Padding( padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), child: CupertinoTextField(
child: Text( decoration: const BoxDecoration(),
AppLocalizations.of(context).players, maxLength: 16,
style: CustomTheme.rowTitle, prefix: Text(AppLocalizations.of(context).name),
textAlign: TextAlign.right,
placeholder: AppLocalizations.of(context).game_title,
controller: _gameTitleTextController,
),
), ),
), Padding(
Expanded( padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: ListView.builder( child: CupertinoTextField(
itemCount: _playerNameTextControllers.length + 1, decoration: const BoxDecoration(),
itemBuilder: (context, index) { readOnly: true,
if (index == _playerNameTextControllers.length) { prefix: Text(AppLocalizations.of(context).mode),
return Padding( suffix: Row(
padding: const EdgeInsets.symmetric(vertical: 8.0), children: [
child: CupertinoButton( _getDisplayedGameMode(),
padding: EdgeInsets.zero, const SizedBox(width: 3),
child: Row( const CupertinoListTileChevron(),
mainAxisAlignment: MainAxisAlignment.center, ],
children: [ ),
const Icon( onTap: () async {
CupertinoIcons.add_circled, final selectedMode = await Navigator.push(
color: CupertinoColors.activeGreen, context,
size: 25, CupertinoPageRoute(
), builder: (context) => ModeSelectionMenu(
const SizedBox(width: 8), pointLimit: ConfigService.getPointLimit(),
Text( showDeselection: false,
AppLocalizations.of(context).add_player,
style: const TextStyle(
color: CupertinoColors.activeGreen,
),
),
],
), ),
onPressed: () {
if (_playerNameTextControllers.length < maxPlayers) {
setState(() {
_playerNameTextControllers
.add(TextEditingController());
});
} else {
showFeedbackDialog(CreateStatus.maxPlayers);
}
},
), ),
); );
} else {
// Spieler-Einträge setState(() {
gameMode = selectedMode ?? gameMode;
});
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).players,
style: CustomTheme.rowTitle,
),
),
ReorderableListView.builder(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(8),
itemCount: _playerNameTextControllers.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (oldIndex < _playerNameTextControllers.length &&
newIndex <= _playerNameTextControllers.length) {
if (newIndex > oldIndex) newIndex--;
final item =
_playerNameTextControllers.removeAt(oldIndex);
_playerNameTextControllers.insert(newIndex, item);
}
});
},
itemBuilder: (context, index) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric( key: ValueKey(index),
vertical: 8.0, horizontal: 5), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row( child: Row(
children: [ children: [
CupertinoButton( CupertinoButton(
@@ -204,78 +183,148 @@ class _CreateGameViewState extends State<CreateGameView> {
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
), ),
), ),
AnimatedOpacity(
opacity: _playerNameTextControllers.length > 1
? 1.0
: 0.0,
duration: const Duration(milliseconds: 300),
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ReorderableDragStartListener(
index: index,
child: const Icon(
CupertinoIcons.line_horizontal_3,
color: CupertinoColors.systemGrey,
),
),
),
)
], ],
), ),
); );
} }),
}, Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 50),
child: Center(
child: SizedBox(
width: double.infinity,
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
AppLocalizations.of(context).add_player,
style: TextStyle(color: CustomTheme.primaryColor),
),
const SizedBox(width: 8),
Icon(
CupertinoIcons.add_circled_solid,
color: CustomTheme.primaryColor,
size: 25,
),
],
),
onPressed: () {
if (_playerNameTextControllers.length < maxPlayers) {
setState(() {
_playerNameTextControllers
.add(TextEditingController());
});
} else {
_showFeedbackDialog(CreateStatus.maxPlayers);
}
},
),
),
),
), ),
), Padding(
Center( padding: const EdgeInsets.fromLTRB(0, 0, 0, 50),
child: CupertinoButton( child: Center(
padding: EdgeInsets.zero, key: const ValueKey('create_game_button'),
child: Row( child: CustomButton(
mainAxisAlignment: MainAxisAlignment.center, child: Text(
children: [
Text(
AppLocalizations.of(context).create_game, AppLocalizations.of(context).create_game,
style: const TextStyle( style: TextStyle(
color: CupertinoColors.activeGreen, color: CustomTheme.primaryColor,
), ),
), ),
], onPressed: () {
_checkAllGameAttributes();
},
),
), ),
onPressed: () async {
if (_gameTitleTextController.text == '') {
showFeedbackDialog(CreateStatus.noGameTitle);
return;
}
if (gameMode == GameMode.none) {
showFeedbackDialog(CreateStatus.noModeSelected);
return;
}
if (_playerNameTextControllers.length < 2) {
showFeedbackDialog(CreateStatus.minPlayers);
return;
}
if (!everyPlayerHasAName()) {
showFeedbackDialog(CreateStatus.noPlayerName);
return;
}
List<String> players = [];
for (var controller in _playerNameTextControllers) {
players.add(controller.text);
}
bool isPointsLimitEnabled = gameMode == GameMode.pointLimit;
GameSession gameSession = GameSession(
createdAt: DateTime.now(),
gameTitle: _gameTitleTextController.text,
players: players,
pointLimit: ConfigService.getPointLimit(),
caboPenalty: ConfigService.getCaboPenalty(),
isPointsLimitEnabled: isPointsLimitEnabled,
);
final index = await gameManager.addGameSession(gameSession);
final session = gameManager.gameList[index];
if (context.mounted) {
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(gameSession: session)));
}
},
), ),
), KeyboardVisibilityBuilder(builder: (context, visible) {
], if (visible) {
)))); return SizedBox(
height: MediaQuery.of(context).viewInsets.bottom *
keyboardHeightAdjustmentFactor,
);
} else {
return const SizedBox.shrink();
}
})
],
),
)));
}
/// Returns a widget that displays the currently selected game mode in the View.
Text _getDisplayedGameMode() {
if (gameMode == GameMode.none) {
return Text(AppLocalizations.of(context).no_mode_selected);
} else if (gameMode == GameMode.pointLimit) {
return Text(
'${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}',
style: TextStyle(color: CustomTheme.primaryColor));
} else {
return Text(AppLocalizations.of(context).unlimited,
style: TextStyle(color: CustomTheme.primaryColor));
}
}
/// Checks all game attributes before creating a new game.
/// If any attribute is invalid, it shows a feedback dialog.
/// If all attributes are valid, it calls the `_createGame` method.
void _checkAllGameAttributes() {
if (_gameTitleTextController.text == '') {
_showFeedbackDialog(CreateStatus.noGameTitle);
return;
}
if (gameMode == GameMode.none) {
_showFeedbackDialog(CreateStatus.noModeSelected);
return;
}
if (_playerNameTextControllers.length < 2) {
_showFeedbackDialog(CreateStatus.minPlayers);
return;
}
if (!_everyPlayerHasAName()) {
_showFeedbackDialog(CreateStatus.noPlayerName);
return;
}
_createGame();
}
/// Checks if every player has a name.
/// Returns true if all players have a name, false otherwise.
bool _everyPlayerHasAName() {
for (var controller in _playerNameTextControllers) {
if (controller.text == '') {
return false;
}
}
return true;
} }
/// Displays a feedback dialog based on the [CreateStatus]. /// Displays a feedback dialog based on the [CreateStatus].
void showFeedbackDialog(CreateStatus status) { void _showFeedbackDialog(CreateStatus status) {
final (title, message) = _getDialogContent(status); final (title, message) = _getDialogContent(status);
showCupertinoDialog( showCupertinoDialog(
@@ -326,15 +375,36 @@ class _CreateGameViewState extends State<CreateGameView> {
} }
} }
/// Checks if every player has a name. /// Creates a new gameSession and navigates to the active game view.
/// Returns true if all players have a name, false otherwise. /// This method creates a new gameSession object with the provided attributes in the text fields.
bool everyPlayerHasAName() { /// It then adds the game session to the game manager and navigates to the active game view.
void _createGame() {
var uuid = const Uuid();
final String id = uuid.v1();
List<String> players = [];
for (var controller in _playerNameTextControllers) { for (var controller in _playerNameTextControllers) {
if (controller.text == '') { players.add(controller.text);
return false;
}
} }
return true;
bool isPointsLimitEnabled = gameMode == GameMode.pointLimit;
GameSession gameSession = GameSession(
id: id,
createdAt: DateTime.now(),
gameTitle: _gameTitleTextController.text,
players: players,
pointLimit: ConfigService.getPointLimit(),
caboPenalty: ConfigService.getCaboPenalty(),
isPointsLimitEnabled: isPointsLimitEnabled,
);
gameManager.addGameSession(gameSession);
final session = gameManager.getGameSessionById(id) ?? gameSession;
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (context) => ActiveGameView(gameSession: session)));
} }
@override @override

View File

@@ -1,6 +1,7 @@
import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/widgets/custom_button.dart';
import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -228,10 +229,7 @@ class _RoundViewState extends State<RoundView> {
padding: const EdgeInsets.fromLTRB(0, 10, 0, 0), padding: const EdgeInsets.fromLTRB(0, 10, 0, 0),
child: Center( child: Center(
heightFactor: 1, heightFactor: 1,
child: CupertinoButton( child: CustomButton(
sizeStyle: CupertinoButtonSize.medium,
borderRadius: BorderRadius.circular(12),
color: CustomTheme.buttonBackgroundColor,
onPressed: () async { onPressed: () async {
if (await _showKamikazeSheet(context)) { if (await _showKamikazeSheet(context)) {
if (!context.mounted) return; if (!context.mounted) return;

View File

@@ -16,6 +16,7 @@ class _TabViewState extends State<TabView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CupertinoTabScaffold( return CupertinoTabScaffold(
resizeToAvoidBottomInset: false,
tabBar: CupertinoTabBar( tabBar: CupertinoTabBar(
backgroundColor: CustomTheme.mainElementBackgroundColor, backgroundColor: CustomTheme.mainElementBackgroundColor,
iconSize: 27, iconSize: 27,

View File

@@ -0,0 +1,19 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:flutter/cupertino.dart';
class CustomButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
const CustomButton({super.key, required this.child, this.onPressed});
@override
Widget build(BuildContext context) {
return CupertinoButton(
sizeStyle: CupertinoButtonSize.medium,
borderRadius: BorderRadius.circular(12),
color: CustomTheme.buttonBackgroundColor,
onPressed: onPressed,
child: child,
);
}
}

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo" description: "Mobile app for the card game Cabo"
publish_to: 'none' publish_to: 'none'
version: 0.5.0+544 version: 0.5.1+568
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -28,6 +28,8 @@ dependencies:
syncfusion_flutter_charts: ^30.1.37 syncfusion_flutter_charts: ^30.1.37
uuid: ^4.5.1 uuid: ^4.5.1
rate_my_app: ^2.3.2 rate_my_app: ^2.3.2
reorderables: ^0.4.2
collection: ^1.18.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -9,6 +9,7 @@ void main() {
setUp(() { setUp(() {
session = GameSession( session = GameSession(
id: '1',
createdAt: testDate, createdAt: testDate,
gameTitle: testTitle, gameTitle: testTitle,
players: testPlayers, players: testPlayers,