diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart index c01250b..2fe2fc5 100644 --- a/lib/presentation/views/main_menu/create_group_view.dart +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -1,17 +1,13 @@ -import 'package:flutter/material.dart' hide ButtonStyle; +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/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/custom_search_bar.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/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'; import 'package:provider/provider.dart'; -import 'package:skeletonizer/skeletonizer.dart'; class CreateGroupView extends StatefulWidget { const CreateGroupView({super.key}); @@ -21,53 +17,25 @@ class CreateGroupView extends StatefulWidget { } class _CreateGroupViewState extends State { - List selectedPlayers = []; - List suggestedPlayers = []; - List allPlayers = []; - late final AppDatabase db; - late Future> _allPlayersFuture; - late final List skeletonData = List.filled( - 7, - Player(name: 'Player 0'), - ); final _groupNameController = TextEditingController(); - final _searchBarController = TextEditingController(); + late final AppDatabase db; + List selectedPlayers = []; @override void initState() { super.initState(); db = Provider.of(context, listen: false); - _searchBarController.addListener(() { - setState(() {}); - }); _groupNameController.addListener(() { setState(() {}); }); - loadPlayerList(); } @override void dispose() { _groupNameController.dispose(); - _searchBarController.dispose(); super.dispose(); } - void loadPlayerList() { - _allPlayersFuture = Future.delayed( - const Duration(milliseconds: 250), - () => db.playerDao.getAllPlayers(), - ); - suggestedPlayers = skeletonData; - _allPlayersFuture.then((loadedPlayers) { - setState(() { - loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); - allPlayers = [...loadedPlayers]; - suggestedPlayers = [...loadedPlayers]; - }); - }); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -96,204 +64,10 @@ class _CreateGroupViewState extends State { ), ), 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( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CustomSearchBar( - controller: _searchBarController, - constraints: const BoxConstraints( - maxHeight: 45, - minHeight: 45, - ), - hintText: 'Search for players', - trailingButtonShown: true, - trailingButtonicon: Icons.add_circle, - trailingButtonEnabled: _searchBarController.text - .trim() - .isNotEmpty, - onTrailingButtonPressed: () async { - addNewPlayerFromSearch(context: context); - }, - onChanged: (value) { - setState(() { - if (value.isEmpty) { - suggestedPlayers = allPlayers.where((player) { - return !selectedPlayers.contains(player); - }).toList(); - } else { - suggestedPlayers = allPlayers.where((player) { - final bool nameMatches = player.name - .toLowerCase() - .contains(value.toLowerCase()); - final bool isNotSelected = !selectedPlayers - .contains(player); - return nameMatches && isNotSelected; - }).toList(); - } - }); - }, - ), - const SizedBox(height: 10), - Text( - 'Ausgewählte Spieler: (${selectedPlayers.length})', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - spacing: 8.0, - runSpacing: 8.0, - children: [ - for (var player in selectedPlayers) - TextIconTile( - text: player.name, - onIconTap: () { - setState(() { - final currentSearch = _searchBarController.text - .toLowerCase(); - selectedPlayers.remove(player); - if (currentSearch.isEmpty || - player.name.toLowerCase().contains( - currentSearch, - )) { - suggestedPlayers.add(player); - suggestedPlayers.sort( - (a, b) => a.name.compareTo(b.name), - ); - } - }); - }, - ), - ], - ), - const SizedBox(height: 10), - const Text( - 'Alle Spieler:', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - FutureBuilder( - future: _allPlayersFuture, - builder: - ( - BuildContext context, - AsyncSnapshot> snapshot, - ) { - if (snapshot.hasError) { - return const Center( - child: TopCenteredMessage( - icon: Icons.report, - title: 'Error', - message: 'Player data couldn\'t\nbe loaded.', - ), - ); - } - bool doneLoading = - snapshot.connectionState == - ConnectionState.done; - bool snapshotDataEmpty = - !snapshot.hasData || snapshot.data!.isEmpty; - if (doneLoading && - (snapshotDataEmpty && allPlayers.isEmpty)) { - return const Center( - child: TopCenteredMessage( - icon: Icons.info, - title: 'Info', - message: 'No players created yet.', - ), - ); - } - final bool isLoading = - snapshot.connectionState == - ConnectionState.waiting; - return Expanded( - child: Skeletonizer( - effect: PulseEffect( - from: Colors.grey[800]!, - to: Colors.grey[600]!, - duration: const Duration(milliseconds: 800), - ), - enabled: isLoading, - enableSwitchAnimation: true, - switchAnimationConfig: - const SwitchAnimationConfig( - duration: Duration(milliseconds: 200), - switchInCurve: Curves.linear, - switchOutCurve: Curves.linear, - transitionBuilder: AnimatedSwitcher - .defaultTransitionBuilder, - layoutBuilder: - AnimatedSwitcher.defaultLayoutBuilder, - ), - child: Visibility( - visible: - (suggestedPlayers.isEmpty && - allPlayers.isNotEmpty), - replacement: ListView.builder( - itemCount: suggestedPlayers.length, - itemBuilder: - (BuildContext context, int index) { - return TextIconListTile( - text: suggestedPlayers[index].name, - onPressed: () { - setState(() { - if (!selectedPlayers.contains( - suggestedPlayers[index], - )) { - selectedPlayers.add( - suggestedPlayers[index], - ); - selectedPlayers.sort( - (a, b) => a.name.compareTo( - b.name, - ), - ); - suggestedPlayers.remove( - suggestedPlayers[index], - ); - } - }); - }, - ); - }, - ), - child: TopCenteredMessage( - icon: Icons.info, - title: 'Info', - message: - (selectedPlayers.length == - allPlayers.length) - ? 'No more players to add.' - : 'No players found with that name.', - ), - ), - ), - ); - }, - ), - ], - ), + child: PlayerSelection( + onChanged: (value) { + selectedPlayers = [...value]; + }, ), ), CustomWidthButton( @@ -312,9 +86,6 @@ class _CreateGroupViewState extends State { ); if (!context.mounted) return; if (success) { - _groupNameController.clear(); - _searchBarController.clear(); - selectedPlayers.clear(); Navigator.pop(context); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -338,47 +109,4 @@ class _CreateGroupViewState extends State { ), ); } - - /// Adds a new player to the database from the search bar input. - /// Shows a snackbar indicating success or failure. - /// [context] - BuildContext to show the snackbar. - void addNewPlayerFromSearch({required BuildContext context}) async { - String playerName = _searchBarController.text.trim(); - Player createdPlayer = Player(name: playerName); - bool success = await db.playerDao.addPlayer(player: createdPlayer); - if (!context.mounted) return; - if (success) { - selectedPlayers.add(createdPlayer); - allPlayers.add(createdPlayer); - setState(() { - _searchBarController.clear(); - suggestedPlayers = allPlayers.where((player) { - return !selectedPlayers.contains(player); - }).toList(); - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: CustomTheme.boxColor, - content: Center( - child: Text( - 'Successfully added player $playerName.', - style: const TextStyle(color: Colors.white), - ), - ), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: CustomTheme.boxColor, - content: Center( - child: Text( - 'Could not add player $playerName.', - style: const TextStyle(color: Colors.white), - ), - ), - ), - ); - } - } } diff --git a/lib/presentation/views/main_menu/groups_view.dart b/lib/presentation/views/main_menu/groups_view.dart index aaef1a5..29fbac8 100644 --- a/lib/presentation/views/main_menu/groups_view.dart +++ b/lib/presentation/views/main_menu/groups_view.dart @@ -4,11 +4,11 @@ import 'package:game_tracker/data/db/database.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/app_skeleton.dart'; import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; import 'package:game_tracker/presentation/widgets/tiles/group_tile.dart'; import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; import 'package:provider/provider.dart'; -import 'package:skeletonizer/skeletonizer.dart'; class GroupsView extends StatefulWidget { const GroupsView({super.key}); @@ -75,22 +75,8 @@ class _GroupsViewState extends State { final List groups = isLoading ? skeletonData : (snapshot.data ?? []) ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - return Skeletonizer( - effect: PulseEffect( - from: Colors.grey[800]!, - to: Colors.grey[600]!, - duration: const Duration(milliseconds: 800), - ), + return AppSkeleton( enabled: isLoading, - enableSwitchAnimation: true, - switchAnimationConfig: const SwitchAnimationConfig( - duration: Duration(milliseconds: 200), - switchInCurve: Curves.linear, - switchOutCurve: Curves.linear, - transitionBuilder: - AnimatedSwitcher.defaultTransitionBuilder, - layoutBuilder: AnimatedSwitcher.defaultLayoutBuilder, - ), child: ListView.builder( padding: const EdgeInsets.only(bottom: 85), itemCount: groups.length + 1, diff --git a/lib/presentation/views/main_menu/home_view.dart b/lib/presentation/views/main_menu/home_view.dart index f75eb78..1667f2b 100644 --- a/lib/presentation/views/main_menu/home_view.dart +++ b/lib/presentation/views/main_menu/home_view.dart @@ -3,12 +3,12 @@ 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/widgets/app_skeleton.dart'; import 'package:game_tracker/presentation/widgets/buttons/quick_create_button.dart'; import 'package:game_tracker/presentation/widgets/tiles/game_tile.dart'; import 'package:game_tracker/presentation/widgets/tiles/info_tile.dart'; import 'package:game_tracker/presentation/widgets/tiles/quick_info_tile.dart'; import 'package:provider/provider.dart'; -import 'package:skeletonizer/skeletonizer.dart'; class HomeView extends StatefulWidget { const HomeView({super.key}); @@ -62,30 +62,8 @@ class _HomeViewState extends State { Widget build(BuildContext context) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - return Skeletonizer( - effect: PulseEffect( - from: Colors.grey[800]!, - to: Colors.grey[600]!, - duration: const Duration(milliseconds: 800), - ), + return AppSkeleton( enabled: isLoading, - enableSwitchAnimation: true, - switchAnimationConfig: SwitchAnimationConfig( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.linear, - switchOutCurve: Curves.linear, - transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, - layoutBuilder: - (Widget? currentChild, List previousChildren) { - return Stack( - alignment: Alignment.topCenter, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], - ); - }, - ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/presentation/views/main_menu/statistics_view.dart b/lib/presentation/views/main_menu/statistics_view.dart index 6107586..564d0d5 100644 --- a/lib/presentation/views/main_menu/statistics_view.dart +++ b/lib/presentation/views/main_menu/statistics_view.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.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/app_skeleton.dart'; import 'package:game_tracker/presentation/widgets/tiles/statistics_tile.dart'; import 'package:provider/provider.dart'; -import 'package:skeletonizer/skeletonizer.dart'; class StatisticsView extends StatefulWidget { const StatisticsView({super.key}); @@ -48,30 +48,9 @@ class _StatisticsViewState extends State { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return SingleChildScrollView( - child: Skeletonizer( - effect: PulseEffect( - from: Colors.grey[800]!, - to: Colors.grey[600]!, - duration: const Duration(milliseconds: 800), - ), + child: AppSkeleton( enabled: isLoading, - enableSwitchAnimation: true, - switchAnimationConfig: SwitchAnimationConfig( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.linear, - switchOutCurve: Curves.linear, - transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, - layoutBuilder: - (Widget? currentChild, List previousChildren) { - return Stack( - alignment: Alignment.topCenter, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], - ); - }, - ), + fixLayoutBuilder: true, child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: Column( diff --git a/lib/presentation/widgets/app_skeleton.dart b/lib/presentation/widgets/app_skeleton.dart new file mode 100644 index 0000000..209f1d8 --- /dev/null +++ b/lib/presentation/widgets/app_skeleton.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class AppSkeleton extends StatefulWidget { + final Widget child; + final bool enabled; + final bool fixLayoutBuilder; + + const AppSkeleton({ + super.key, + required this.child, + this.enabled = true, + this.fixLayoutBuilder = false, + }); + + @override + State createState() => _AppSkeletonState(); +} + +class _AppSkeletonState extends State { + @override + Widget build(BuildContext context) { + return Skeletonizer( + effect: PulseEffect( + from: Colors.grey[800]!, + to: Colors.grey[600]!, + duration: const Duration(milliseconds: 800), + ), + enabled: widget.enabled, + enableSwitchAnimation: true, + switchAnimationConfig: SwitchAnimationConfig( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.linear, + switchOutCurve: Curves.linear, + transitionBuilder: AnimatedSwitcher.defaultTransitionBuilder, + layoutBuilder: !widget.fixLayoutBuilder + ? AnimatedSwitcher.defaultLayoutBuilder + : (Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + ), + child: widget.child, + ); + } +} diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart new file mode 100644 index 0000000..ad15363 --- /dev/null +++ b/lib/presentation/widgets/player_selection.dart @@ -0,0 +1,261 @@ +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/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/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'; +import 'package:provider/provider.dart'; + +class PlayerSelection extends StatefulWidget { + final Function(List value) onChanged; + + const PlayerSelection({super.key, required this.onChanged}); + + @override + State createState() => _PlayerSelectionState(); +} + +class _PlayerSelectionState extends State { + List selectedPlayers = []; + List suggestedPlayers = []; + List allPlayers = []; + late final TextEditingController _searchBarController = + TextEditingController(); + late final AppDatabase db; + late Future> _allPlayersFuture; + late final List skeletonData = List.filled( + 7, + Player(name: 'Player 0'), + ); + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + loadPlayerList(); + } + + void loadPlayerList() { + _allPlayersFuture = Future.delayed( + const Duration(milliseconds: 250), + () => db.playerDao.getAllPlayers(), + ); + suggestedPlayers = skeletonData; + _allPlayersFuture.then((loadedPlayers) { + setState(() { + loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); + allPlayers = [...loadedPlayers]; + suggestedPlayers = [...loadedPlayers]; + }); + }); + } + + @override + Widget build(BuildContext context) { + 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), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomSearchBar( + controller: _searchBarController, + constraints: const BoxConstraints(maxHeight: 45, minHeight: 45), + hintText: 'Search for players', + trailingButtonShown: true, + trailingButtonicon: Icons.add_circle, + trailingButtonEnabled: _searchBarController.text.trim().isNotEmpty, + onTrailingButtonPressed: () async { + addNewPlayerFromSearch(context: context); + }, + onChanged: (value) { + setState(() { + // Filters the list of suggested players based on the search input. + if (value.isEmpty) { + // If the search is empty, it shows all unselected players. + suggestedPlayers = allPlayers.where((player) { + return !selectedPlayers.contains(player); + }).toList(); + } else { + // If there is input, it filters by name match (case-insensitive) and ensures + // that already selected players are excluded from the results. + suggestedPlayers = allPlayers.where((player) { + final bool nameMatches = player.name.toLowerCase().contains( + value.toLowerCase(), + ); + final bool isNotSelected = !selectedPlayers.contains( + player, + ); + return nameMatches && isNotSelected; + }).toList(); + } + }); + }, + ), + const SizedBox(height: 10), + Text( + 'Selected players: (${selectedPlayers.length})', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 8.0, + runSpacing: 8.0, + children: [ + // Generates a TextIconTile for each selected player. + for (var player in selectedPlayers) + TextIconTile( + text: player.name, + onIconTap: () { + setState(() { + // Removes the player from the selection and notifies the parent. + final currentSearch = _searchBarController.text + .toLowerCase(); + selectedPlayers.remove(player); + widget.onChanged([...selectedPlayers]); + // If the player matches the current search query (or search is empty), + // they are added back to the suggestions and the list is re-sorted. + if (currentSearch.isEmpty || + player.name.toLowerCase().contains(currentSearch)) { + suggestedPlayers.add(player); + } + }); + }, + ), + ], + ), + const SizedBox(height: 10), + const Text( + 'All players:', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + FutureBuilder( + future: _allPlayersFuture, + builder: + (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasError) { + return const Center( + child: TopCenteredMessage( + icon: Icons.report, + title: 'Error', + message: 'Player data couldn\'t\nbe loaded.', + ), + ); + } + bool doneLoading = + snapshot.connectionState == ConnectionState.done; + bool snapshotDataEmpty = + !snapshot.hasData || snapshot.data!.isEmpty; + if (doneLoading && + (snapshotDataEmpty && allPlayers.isEmpty)) { + return const Center( + child: TopCenteredMessage( + icon: Icons.info, + title: 'Info', + message: 'No players created yet.', + ), + ); + } + final bool isLoading = + snapshot.connectionState == ConnectionState.waiting; + return Expanded( + child: AppSkeleton( + enabled: isLoading, + child: Visibility( + visible: + (suggestedPlayers.isEmpty && allPlayers.isNotEmpty), + replacement: ListView.builder( + itemCount: suggestedPlayers.length, + itemBuilder: (BuildContext context, int index) { + return TextIconListTile( + text: suggestedPlayers[index].name, + onPressed: () { + setState(() { + if (!selectedPlayers.contains( + suggestedPlayers[index], + )) { + selectedPlayers.add( + suggestedPlayers[index], + ); + widget.onChanged([...selectedPlayers]); + suggestedPlayers.remove( + suggestedPlayers[index], + ); + } + }); + }, + ); + }, + ), + child: TopCenteredMessage( + icon: Icons.info, + title: 'Info', + message: (selectedPlayers.length == allPlayers.length) + ? 'No more players to add.' + : 'No players found with that name.', + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + /// Adds a new player to the database from the search bar input. + /// Shows a snackbar indicating success or failure. + /// [context] - BuildContext to show the snackbar. + void addNewPlayerFromSearch({required BuildContext context}) async { + String playerName = _searchBarController.text.trim(); + Player createdPlayer = Player(name: playerName); + bool success = await db.playerDao.addPlayer(player: createdPlayer); + if (!context.mounted) return; + if (success) { + selectedPlayers.add(createdPlayer); + widget.onChanged([...selectedPlayers]); + allPlayers.add(createdPlayer); + setState(() { + _searchBarController.clear(); + suggestedPlayers = allPlayers.where((player) { + return !selectedPlayers.contains(player); + }).toList(); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: Center( + child: Text( + 'Successfully added player $playerName.', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: Center( + child: Text( + 'Could not add player $playerName.', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ); + } + } +}