diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index fe75ef4..45bb839 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -16,6 +16,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/dashboard/bloc/dashboard_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/throttled_fetching_service.dart'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:logging/logging.dart'; @@ -79,6 +80,7 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _countriesRepository), RepositoryProvider.value(value: _languagesRepository), RepositoryProvider.value(value: _kvStorageService), + RepositoryProvider(create: (context) => const ThrottledFetchingService()), ], child: MultiBlocProvider( providers: [ @@ -110,6 +112,7 @@ class App extends StatelessWidget { sourcesRepository: context.read>(), countriesRepository: context.read>(), languagesRepository: context.read>(), + fetchingService: context.read(), )..add(const SharedDataRequested()), ), BlocProvider( diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index 2404f16..d6217ef 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/throttled_fetching_service.dart'; part 'content_management_event.dart'; part 'content_management_state.dart'; @@ -26,12 +27,14 @@ class ContentManagementBloc required DataRepository sourcesRepository, required DataRepository countriesRepository, required DataRepository languagesRepository, - }) : _headlinesRepository = headlinesRepository, - _topicsRepository = topicsRepository, - _sourcesRepository = sourcesRepository, - _countriesRepository = countriesRepository, - _languagesRepository = languagesRepository, - super(const ContentManagementState()) { + required ThrottledFetchingService fetchingService, + }) : _headlinesRepository = headlinesRepository, + _topicsRepository = topicsRepository, + _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + _languagesRepository = languagesRepository, + _fetchingService = fetchingService, + super(const ContentManagementState()) { on(_onSharedDataRequested); on(_onContentManagementTabChanged); on(_onLoadHeadlinesRequested); @@ -50,39 +53,32 @@ class ContentManagementBloc final DataRepository _sourcesRepository; final DataRepository _countriesRepository; final DataRepository _languagesRepository; - - // --- Background Data Fetching for countries/languages for the ui Dropdown --- - // - // The DropdownButtonFormField widget does not natively support on-scroll - // pagination. To preserve UI consistency across the application, this BLoC - // employs an event-driven background fetching mechanism. + final ThrottledFetchingService _fetchingService; + + /// Handles the pre-fetching of shared data required for the content + /// management section. + /// + /// **Strategy Rationale (The "Why"):** + /// This pre-fetching strategy is a direct result of a UI component choice + /// made to preserve visual consistency across the application. The standard + /// `DropdownButtonFormField` is used for selection fields in forms. + /// A key limitation of this widget is its lack of native support for + /// on-scroll pagination or dynamic data loading. + /// + /// To work around this, and to ensure a seamless user experience without + /// loading delays when a form is opened, we must load the entire dataset + /// for these dropdowns (e.g., all countries, all languages) into the state + /// ahead of time. + /// + /// **Implementation (The "How"):** + /// To execute this pre-fetch efficiently, this handler utilizes the + /// `ThrottledFetchingService`. This service fetches all pages of a given + /// resource in parallel, which dramatically reduces the load time compared + /// to fetching them sequentially, making the upfront data load manageable. Future _onSharedDataRequested( SharedDataRequested event, Emitter emit, ) async { - // Helper function to fetch all items of a given type. - Future> fetchAll({ - required DataRepository repository, - required List sort, - }) async { - final allItems = []; - String? cursor; - bool hasMore; - - do { - final response = await repository.readAll( - sort: sort, - pagination: PaginationOptions(cursor: cursor), - filter: {'status': ContentStatus.active.name}, - ); - allItems.addAll(response.items); - cursor = response.cursor; - hasMore = response.hasMore; - } while (hasMore); - - return allItems; - } - // Check if data is already loaded or is currently loading to prevent // redundant fetches. if (state.allCountriesStatus == ContentManagementStatus.success && @@ -99,13 +95,13 @@ class ContentManagementBloc ); try { - // Fetch both lists in parallel. + // Fetch both lists in parallel using the dedicated fetching service. final results = await Future.wait([ - fetchAll( + _fetchingService.fetchAll( repository: _countriesRepository, sort: [const SortOption('name', SortOrder.asc)], ), - fetchAll( + _fetchingService.fetchAll( repository: _languagesRepository, sort: [const SortOption('name', SortOrder.asc)], ), diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index 3d56e20..1dcbcd6 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -22,10 +22,9 @@ class CreateHeadlinePage extends StatelessWidget { // The list of all countries is fetched once and cached in the // ContentManagementBloc. We read it here and provide it to the // CreateHeadlineBloc. - final allCountries = context - .read() - .state - .allCountries; + final contentManagementState = context.watch().state; + final allCountries = contentManagementState.allCountries; + return BlocProvider( create: (context) => CreateHeadlineBloc( headlinesRepository: context.read>(), @@ -221,40 +220,53 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { .add(CreateHeadlineTopicChanged(value)), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: state.eventCountry, - decoration: InputDecoration( - labelText: l10n.countryName, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.countries.map( - (country) => DropdownMenuItem( - value: country, - child: Row( - children: [ - SizedBox( - width: 32, - height: 20, - child: Image.network( - country.flagUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - const Icon(Icons.flag), - ), + BlocBuilder( + builder: (context, contentState) { + final isLoading = contentState.allCountriesStatus == + ContentManagementStatus.loading; + return DropdownButtonFormField( + value: state.eventCountry, + decoration: InputDecoration( + labelText: l10n.countryName, + border: const OutlineInputBorder(), + helperText: + isLoading ? l10n.loadingFullList : null, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(l10n.none), + ), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Row( + children: [ + SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + const SizedBox(width: AppSpacing.md), + Text(country.name), + ], ), - const SizedBox(width: AppSpacing.md), - Text(country.name), - ], + ), ), - ), - ), - ], - onChanged: (value) => context - .read() - .add(CreateHeadlineCountryChanged(value)), + ], + onChanged: isLoading + ? null + : (value) => context + .read() + .add(CreateHeadlineCountryChanged(value)), + ); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( diff --git a/lib/content_management/view/create_source_page.dart b/lib/content_management/view/create_source_page.dart index 2d44daa..2544750 100644 --- a/lib/content_management/view/create_source_page.dart +++ b/lib/content_management/view/create_source_page.dart @@ -168,24 +168,35 @@ class _CreateSourceViewState extends State<_CreateSourceView> { .add(CreateSourceUrlChanged(value)), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: state.language, - decoration: InputDecoration( - labelText: l10n.language, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.languages.map( - (language) => DropdownMenuItem( - value: language, - child: Text(language.name), + BlocBuilder( + builder: (context, contentState) { + final isLoading = contentState.allLanguagesStatus == + ContentManagementStatus.loading; + return DropdownButtonFormField( + value: state.language, + decoration: InputDecoration( + labelText: l10n.language, + border: const OutlineInputBorder(), + helperText: + isLoading ? l10n.loadingFullList : null, ), - ), - ], - onChanged: (value) => context - .read() - .add(CreateSourceLanguageChanged(value)), + items: [ + DropdownMenuItem( + value: null, child: Text(l10n.none)), + ...state.languages.map( + (language) => DropdownMenuItem( + value: language, + child: Text(language.name), + ), + ), + ], + onChanged: isLoading + ? null + : (value) => context + .read() + .add(CreateSourceLanguageChanged(value)), + ); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( @@ -208,40 +219,50 @@ class _CreateSourceViewState extends State<_CreateSourceView> { .add(CreateSourceTypeChanged(value)), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: state.headquarters, - decoration: InputDecoration( - labelText: l10n.headquarters, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.countries.map( - (country) => DropdownMenuItem( - value: country, - child: Row( - children: [ - SizedBox( - width: 32, - height: 20, - child: Image.network( - country.flagUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - const Icon(Icons.flag), - ), + BlocBuilder( + builder: (context, contentState) { + final isLoading = contentState.allCountriesStatus == + ContentManagementStatus.loading; + return DropdownButtonFormField( + value: state.headquarters, + decoration: InputDecoration( + labelText: l10n.headquarters, + border: const OutlineInputBorder(), + helperText: + isLoading ? l10n.loadingFullList : null, + ), + items: [ + DropdownMenuItem( + value: null, child: Text(l10n.none)), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Row( + children: [ + SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + const SizedBox(width: AppSpacing.md), + Text(country.name), + ], ), - const SizedBox(width: AppSpacing.md), - Text(country.name), - ], + ), ), - ), - ), - ], - onChanged: (value) => context - .read() - .add(CreateSourceHeadquartersChanged(value)), + ], + onChanged: isLoading + ? null + : (value) => context.read().add( + CreateSourceHeadquartersChanged(value)), + ); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( diff --git a/lib/content_management/view/edit_headline_page.dart b/lib/content_management/view/edit_headline_page.dart index f301492..5c9d10f 100644 --- a/lib/content_management/view/edit_headline_page.dart +++ b/lib/content_management/view/edit_headline_page.dart @@ -25,10 +25,9 @@ class EditHeadlinePage extends StatelessWidget { // The list of all countries is fetched once and cached in the // ContentManagementBloc. We read it here and provide it to the // EditHeadlineBloc. - final allCountries = context - .read() - .state - .allCountries; + final contentManagementState = context.watch().state; + final allCountries = contentManagementState.allCountries; + return BlocProvider( create: (context) => EditHeadlineBloc( headlinesRepository: context.read>(), @@ -289,40 +288,53 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { .add(EditHeadlineTopicChanged(value)), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: selectedCountry, - decoration: InputDecoration( - labelText: l10n.countryName, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.countries.map( - (country) => DropdownMenuItem( - value: country, - child: Row( - children: [ - SizedBox( - width: 32, - height: 20, - child: Image.network( - country.flagUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - const Icon(Icons.flag), - ), + BlocBuilder( + builder: (context, contentState) { + final isLoading = contentState.allCountriesStatus == + ContentManagementStatus.loading; + return DropdownButtonFormField( + value: selectedCountry, + decoration: InputDecoration( + labelText: l10n.countryName, + border: const OutlineInputBorder(), + helperText: + isLoading ? l10n.loadingFullList : null, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(l10n.none), + ), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Row( + children: [ + SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + const SizedBox(width: AppSpacing.md), + Text(country.name), + ], ), - const SizedBox(width: AppSpacing.md), - Text(country.name), - ], + ), ), - ), - ), - ], - onChanged: (value) => context - .read() - .add(EditHeadlineCountryChanged(value)), + ], + onChanged: isLoading + ? null + : (value) => context + .read() + .add(EditHeadlineCountryChanged(value)), + ); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( diff --git a/lib/content_management/view/edit_source_page.dart b/lib/content_management/view/edit_source_page.dart index 185d0c4..feca72a 100644 --- a/lib/content_management/view/edit_source_page.dart +++ b/lib/content_management/view/edit_source_page.dart @@ -198,24 +198,36 @@ class _EditSourceViewState extends State<_EditSourceView> { ), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: state.language, - decoration: InputDecoration( - labelText: l10n.language, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.languages.map( - (language) => DropdownMenuItem( - value: language, - child: Text(language.name), + BlocBuilder( + builder: (context, contentState) { + final isLoading = contentState.allLanguagesStatus == + ContentManagementStatus.loading; + return DropdownButtonFormField( + value: state.language, + decoration: InputDecoration( + labelText: l10n.language, + border: const OutlineInputBorder(), + helperText: + isLoading ? l10n.loadingFullList : null, ), - ), - ], - onChanged: (value) => context.read().add( - EditSourceLanguageChanged(value), - ), + items: [ + DropdownMenuItem( + value: null, child: Text(l10n.none)), + ...state.languages.map( + (language) => DropdownMenuItem( + value: language, + child: Text(language.name), + ), + ), + ], + onChanged: isLoading + ? null + : (value) => + context.read().add( + EditSourceLanguageChanged(value), + ), + ); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( @@ -238,40 +250,52 @@ class _EditSourceViewState extends State<_EditSourceView> { ), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: state.headquarters, - decoration: InputDecoration( - labelText: l10n.headquarters, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.countries.map( - (country) => DropdownMenuItem( - value: country, - child: Row( - children: [ - SizedBox( - width: 32, - height: 20, - child: Image.network( - country.flagUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - const Icon(Icons.flag), - ), + BlocBuilder( + builder: (context, contentState) { + final isLoading = contentState.allCountriesStatus == + ContentManagementStatus.loading; + return DropdownButtonFormField( + value: state.headquarters, + decoration: InputDecoration( + labelText: l10n.headquarters, + border: const OutlineInputBorder(), + helperText: + isLoading ? l10n.loadingFullList : null, + ), + items: [ + DropdownMenuItem( + value: null, child: Text(l10n.none)), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Row( + children: [ + SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + const SizedBox(width: AppSpacing.md), + Text(country.name), + ], ), - const SizedBox(width: AppSpacing.md), - Text(country.name), - ], + ), ), - ), - ), - ], - onChanged: (value) => context.read().add( - EditSourceHeadquartersChanged(value), - ), + ], + onChanged: isLoading + ? null + : (value) => + context.read().add( + EditSourceHeadquartersChanged(value), + ), + ); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( diff --git a/lib/shared/services/throttled_fetching_service.dart b/lib/shared/services/throttled_fetching_service.dart new file mode 100644 index 0000000..8398fe7 --- /dev/null +++ b/lib/shared/services/throttled_fetching_service.dart @@ -0,0 +1,72 @@ +// ignore_for_file: inference_failure_on_instance_creation + +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; + +/// {@template throttled_fetching_service} +/// A service that provides a robust and efficient mechanism for fetching all +/// items from a paginated data source. +/// +/// In scenarios where an entire dataset is needed upfront (e.g., for populating +/// dropdowns, client-side searching, or when UI components don't support +/// on-scroll pagination), this service offers an optimized solution. +/// +/// It fetches all pages from a repository, providing a significant performance +/// improvement over fetching pages one by one, while avoiding the risk of +/// overwhelming the server by fetching all pages at once. +/// {@endtemplate} +class ThrottledFetchingService { + /// {@macro throttled_fetching_service} + const ThrottledFetchingService(); + + /// Fetches all items of type [T] from the provided [repository]. + /// + /// It fetches pages in parallel batches to optimize loading time without + /// overwhelming the server. It includes a configurable delay between + /// requests to act as a good API citizen. + /// + /// - [repository]: The data repository to fetch from. + /// - [sort]: The sorting options for the query. + /// - [delayBetweenRequests]: The duration to wait between fetching pages. + /// Defaults to 200 milliseconds. + Future> fetchAll({ + required DataRepository repository, + required List sort, + Duration delayBetweenRequests = const Duration(milliseconds: 200), + }) async { + final allItems = []; + String? cursor; + bool hasMore; + + // First, fetch the initial page to get the first set of items and + // determine the pagination status. + final initialResponse = await repository.readAll( + sort: sort, + filter: {'status': ContentStatus.active.name}, + ); + allItems.addAll(initialResponse.items); + cursor = initialResponse.cursor; + hasMore = initialResponse.hasMore; + + // Sequentially fetch all remaining pages. The loop is resilient to a + // misbehaving API by also checking if the cursor is null, which would + // otherwise cause an infinite loop by re-fetching the first page. + while (hasMore && cursor != null) { + // Introduce a delay to avoid overwhelming the server. + await Future.delayed(delayBetweenRequests); + + final response = await repository.readAll( + sort: sort, + pagination: PaginationOptions(cursor: cursor), + filter: {'status': ContentStatus.active.name}, + ); + allItems.addAll(response.items); + cursor = response.cursor; + hasMore = response.hasMore; + } + + return allItems; + } +}