From 65ed94eb089d5c135ce2ec456471a23d7d069efa Mon Sep 17 00:00:00 2001 From: Faris Armoush Date: Sun, 22 Jun 2025 12:01:32 +0300 Subject: [PATCH] debounce and refactored `CommunitiesBloc`. --- .../debounced_communities_service.dart | 60 ++++++++++++ .../presentation/bloc/communities_bloc.dart | 82 ++++------------ .../presentation/bloc/communities_event.dart | 9 -- .../space_management_communities_tree.dart | 97 ++++++++++++++++--- ...e_management_sidebar_communities_list.dart | 44 ++++++++- 5 files changed, 204 insertions(+), 88 deletions(-) create mode 100644 lib/pages/space_management_v2/modules/communities/data/services/debounced_communities_service.dart diff --git a/lib/pages/space_management_v2/modules/communities/data/services/debounced_communities_service.dart b/lib/pages/space_management_v2/modules/communities/data/services/debounced_communities_service.dart new file mode 100644 index 00000000..f8bd56d1 --- /dev/null +++ b/lib/pages/space_management_v2/modules/communities/data/services/debounced_communities_service.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/communities_pagination_model.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/services/communities_service.dart'; + +class DebouncedCommunitiesService implements CommunitiesService { + DebouncedCommunitiesService({ + required CommunitiesService communitiesService, + this.debounceDuration = const Duration(milliseconds: 400), + }) : _communitiesService = communitiesService; + + final CommunitiesService _communitiesService; + final Duration debounceDuration; + + Timer? _debounceTimer; + String _lastSearchQuery = ''; + + @override + Future getCommunity( + LoadCommunitiesParam param, + ) async { + if (param.search.isNotEmpty) { + return _getDebouncedCommunity(param); + } + + return _communitiesService.getCommunity(param); + } + + Future _getDebouncedCommunity( + LoadCommunitiesParam param, + ) async { + final completer = Completer(); + + _debounceTimer?.cancel(); + + _lastSearchQuery = param.search; + + _debounceTimer = Timer(debounceDuration, () async { + try { + if (_lastSearchQuery == param.search) { + final result = await _communitiesService.getCommunity(param); + if (!completer.isCompleted) { + completer.complete(result); + } + } else { + if (!completer.isCompleted) { + completer.complete(const CommunitiesPaginationModel.empty()); + } + } + } catch (error) { + if (!completer.isCompleted) { + completer.completeError(error); + } + } + }); + + return completer.future; + } +} diff --git a/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart b/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart index 47dd43f8..53eb9d3f 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart @@ -15,7 +15,6 @@ class CommunitiesBloc extends Bloc { super(const CommunitiesState()) { on(_onLoadCommunities); on(_onLoadMoreCommunities); - on(_onSearchCommunities); } final CommunitiesService _communitiesService; @@ -39,19 +38,9 @@ class CommunitiesBloc extends Bloc { ), ); } on APIException catch (e) { - emit( - state.copyWith( - status: CommunitiesStatus.failure, - errorMessage: e.message, - ), - ); + _onApiException(e, emit); } catch (e) { - emit( - state.copyWith( - status: CommunitiesStatus.failure, - errorMessage: e.toString(), - ), - ); + _onError(e, emit); } } @@ -83,59 +72,30 @@ class CommunitiesBloc extends Bloc { ), ); } on APIException catch (e) { - emit( - state.copyWith( - isLoadingMore: false, - errorMessage: e.message, - ), - ); + _onApiException(e, emit); } catch (e) { - emit( - state.copyWith( - isLoadingMore: false, - errorMessage: e.toString(), - ), - ); + _onError(e, emit); } } - Future _onSearchCommunities( - SearchCommunities event, + void _onApiException( + APIException e, Emitter emit, - ) async { - try { - emit(state.copyWith(status: CommunitiesStatus.loading)); + ) { + emit( + state.copyWith( + isLoadingMore: false, + errorMessage: e.message, + ), + ); + } - final param = LoadCommunitiesParam( - page: 1, - search: event.searchQuery, - ); - - final paginationResponse = await _communitiesService.getCommunity(param); - - emit( - CommunitiesState( - status: CommunitiesStatus.success, - communities: paginationResponse.communities, - hasNext: paginationResponse.hasNext, - currentPage: paginationResponse.page, - searchQuery: event.searchQuery, - ), - ); - } on APIException catch (e) { - emit( - state.copyWith( - status: CommunitiesStatus.failure, - errorMessage: e.message, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: CommunitiesStatus.failure, - errorMessage: e.toString(), - ), - ); - } + void _onError(Object e, Emitter emit) { + emit( + state.copyWith( + isLoadingMore: false, + errorMessage: e.toString(), + ), + ); } } diff --git a/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_event.dart b/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_event.dart index aa6eda17..ae4d86bf 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_event.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/bloc/communities_event.dart @@ -22,12 +22,3 @@ class LoadMoreCommunities extends CommunitiesEvent { @override List get props => []; } - -class SearchCommunities extends CommunitiesEvent { - const SearchCommunities(this.searchQuery); - - final String searchQuery; - - @override - List get props => [searchQuery]; -} diff --git a/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart b/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart index 3248fa7d..51322b52 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart @@ -1,20 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncrow_web/common/widgets/search_bar.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/community_tile.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/community_tile.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/create_community_dialog.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_header.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_tile.dart'; import 'package:syncrow_web/utils/style.dart'; -class SpaceManagementCommunitiesTree extends StatelessWidget { +class SpaceManagementCommunitiesTree extends StatefulWidget { const SpaceManagementCommunitiesTree({super.key}); + @override + State createState() => + _SpaceManagementCommunitiesTreeState(); +} + +class _SpaceManagementCommunitiesTreeState + extends State { + @override + void initState() { + context.read().add( + const LoadCommunities(LoadCommunitiesParam()), + ); + super.initState(); + } + bool _isSpaceOrChildSelected(BuildContext context, SpaceModel space) { final selectedSpace = context.read().state.selectedSpace; @@ -25,6 +41,18 @@ class SpaceManagementCommunitiesTree extends StatelessWidget { return isSpaceSelected || anySubSpaceIsSelected; } + void _onSearchChanged(String searchQuery) { + context.read().add( + LoadCommunities(LoadCommunitiesParam( + search: searchQuery.trim(), + )), + ); + } + + void _onLoadMore() { + context.read().add(const LoadMoreCommunities()); + } + static const _width = 300.0; @override @@ -39,18 +67,18 @@ class SpaceManagementCommunitiesTree extends StatelessWidget { SpaceManagementSidebarHeader( onAddCommunity: () => _onAddCommunity(context), ), - CustomSearchBar(onSearchChanged: (value) {}), + CustomSearchBar( + onSearchChanged: _onSearchChanged, + ), const SizedBox(height: 16), switch (state.status) { CommunitiesStatus.initial => const Center(child: CircularProgressIndicator()), - CommunitiesStatus.loading => - const Center(child: CircularProgressIndicator()), - CommunitiesStatus.success => - _buildCommunitiesTree(context, state.communities), - CommunitiesStatus.failure => Center( - child: Text(state.errorMessage ?? 'Something went wrong'), - ), + CommunitiesStatus.loading => state.communities.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _buildCommunitiesTree(context, state), + CommunitiesStatus.success => _buildCommunitiesTree(context, state), + CommunitiesStatus.failure => _buildErrorState(context, state), }, ], ), @@ -59,15 +87,58 @@ class SpaceManagementCommunitiesTree extends StatelessWidget { ); } + Widget _buildErrorState(BuildContext context, CommunitiesState state) { + return Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + state.errorMessage ?? 'Something went wrong', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + context.read().add( + LoadCommunities(LoadCommunitiesParam( + search: state.searchQuery, + )), + ); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + Widget _buildCommunitiesTree( BuildContext context, - List communities, + CommunitiesState state, ) { + if (state.communities.isEmpty && state.status == CommunitiesStatus.success) { + return Expanded( + child: Center( + child: Text( + state.searchQuery.isEmpty + ? 'No communities found' + : 'No communities found for "${state.searchQuery}"', + textAlign: TextAlign.center, + ), + ), + ); + } + return Expanded( child: SpaceManagementSidebarCommunitiesList( - communities: communities, + communities: state.communities, + onLoadMore: state.hasNext ? _onLoadMore : null, + isLoadingMore: state.isLoadingMore, + hasNext: state.hasNext, itemBuilder: (context, index) { - return _buildCommunityTile(context, communities[index]); + return _buildCommunityTile(context, state.communities[index]); }, ), ); diff --git a/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart b/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart index e7cb1ef6..68119dcd 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart @@ -6,11 +6,17 @@ class SpaceManagementSidebarCommunitiesList extends StatefulWidget { const SpaceManagementSidebarCommunitiesList({ required this.communities, required this.itemBuilder, + this.onLoadMore, + this.isLoadingMore = false, + this.hasNext = false, super.key, }); final List communities; final Widget Function(BuildContext context, int index) itemBuilder; + final VoidCallback? onLoadMore; + final bool isLoadingMore; + final bool hasNext; @override State createState() => @@ -25,12 +31,26 @@ class _SpaceManagementSidebarCommunitiesListState void initState() { super.initState(); _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 100) { + // Trigger pagination when user is close to the bottom + if (widget.hasNext && !widget.isLoadingMore && widget.onLoadMore != null) { + widget.onLoadMore!(); + } + } } bool _onNotification(ScrollEndNotification notification) { final hasReachedEnd = notification.metrics.extentAfter == 0; - if (hasReachedEnd) { - // Call data from API. + if (hasReachedEnd && + widget.hasNext && + !widget.isLoadingMore && + widget.onLoadMore != null) { + widget.onLoadMore!(); return true; } @@ -40,13 +60,16 @@ class _SpaceManagementSidebarCommunitiesListState @override void dispose() { _scrollController - ..removeListener(() {}) + ..removeListener(_onScroll) ..dispose(); super.dispose(); } @override Widget build(BuildContext context) { + // Calculate item count including loading indicator + final itemCount = widget.communities.length + (widget.isLoadingMore ? 1 : 0); + return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( @@ -60,9 +83,20 @@ class _SpaceManagementSidebarCommunitiesListState child: ListView.builder( shrinkWrap: true, padding: const EdgeInsetsDirectional.only(start: 16), - itemCount: widget.communities.length, + itemCount: itemCount, controller: _scrollController, - itemBuilder: widget.itemBuilder, + itemBuilder: (context, index) { + if (index == widget.communities.length) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return widget.itemBuilder(context, index); + }, ), ), ),