diff --git a/lib/content_management/bloc/create_headline/create_headline_bloc.dart b/lib/content_management/bloc/create_headline/create_headline_bloc.dart index 5b392cf..43118c1 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -9,6 +9,10 @@ import 'package:uuid/uuid.dart'; part 'create_headline_event.dart'; part 'create_headline_state.dart'; +final class _FetchNextCountryPage extends CreateHeadlineEvent { + const _FetchNextCountryPage(); +} + const _searchDebounceDuration = Duration(milliseconds: 300); /// A BLoC to manage the state of creating a new headline. @@ -20,11 +24,11 @@ class CreateHeadlineBloc required DataRepository sourcesRepository, required DataRepository topicsRepository, required DataRepository countriesRepository, - }) : _headlinesRepository = headlinesRepository, - _sourcesRepository = sourcesRepository, - _topicsRepository = topicsRepository, - _countriesRepository = countriesRepository, - super(const CreateHeadlineState()) { + }) : _headlinesRepository = headlinesRepository, + _sourcesRepository = sourcesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, + super(const CreateHeadlineState()) { on(_onDataLoaded); on(_onTitleChanged); on(_onExcerptChanged); @@ -35,6 +39,7 @@ class CreateHeadlineBloc on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); + on<_FetchNextCountryPage>(_onFetchNextCountryPage); } final DataRepository _headlinesRepository; @@ -76,19 +81,10 @@ class CreateHeadlineBloc ), ); - // Start background fetching for all countries - while (state.countriesHasMore) { - final nextCountries = await _countriesRepository.readAll( - pagination: PaginationOptions(cursor: state.countriesCursor), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - countries: List.of(state.countries)..addAll(nextCountries.items), - countriesCursor: nextCountries.cursor, - countriesHasMore: nextCountries.hasMore, - ), - ); + // After the initial page of countries is loaded, start a background + // process to fetch all remaining pages. + if (state.countriesHasMore) { + add(const _FetchNextCountryPage()); } } on HttpException catch (e) { emit(state.copyWith(status: CreateHeadlineStatus.failure, exception: e)); @@ -163,6 +159,49 @@ class CreateHeadlineBloc ); } + // --- Background Data Fetching for 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. + // + // After the first page of items is loaded, a chain of events is initiated + // to progressively fetch all remaining pages. This process is throttled + // and runs in the background, ensuring the UI remains responsive while the + // full list of dropdown options is populated over time. + Future _onFetchNextCountryPage( + _FetchNextCountryPage event, + Emitter emit, + ) async { + if (!state.countriesHasMore || state.countriesIsLoadingMore) return; + + try { + emit(state.copyWith(countriesIsLoadingMore: true)); + + await Future.delayed(const Duration(milliseconds: 400)); + + final nextCountries = await _countriesRepository.readAll( + pagination: PaginationOptions(cursor: state.countriesCursor), + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(nextCountries.items), + countriesCursor: nextCountries.cursor, + countriesHasMore: nextCountries.hasMore, + countriesIsLoadingMore: false, + ), + ); + + if (nextCountries.hasMore) { + add(const _FetchNextCountryPage()); + } + } catch (e) { + emit(state.copyWith(countriesIsLoadingMore: false)); + // Optionally log the error without disrupting the user + } + } + Future _onSubmitted( CreateHeadlineSubmitted event, Emitter emit, diff --git a/lib/content_management/bloc/create_source/create_source_bloc.dart b/lib/content_management/bloc/create_source/create_source_bloc.dart index 3f534a6..62a1150 100644 --- a/lib/content_management/bloc/create_source/create_source_bloc.dart +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -9,6 +9,14 @@ import 'package:uuid/uuid.dart'; part 'create_source_event.dart'; part 'create_source_state.dart'; +final class _FetchNextCountryPage extends CreateSourceEvent { + const _FetchNextCountryPage(); +} + +final class _FetchNextLanguagePage extends CreateSourceEvent { + const _FetchNextLanguagePage(); +} + const _searchDebounceDuration = Duration(milliseconds: 300); /// A BLoC to manage the state of creating a new source. @@ -18,10 +26,10 @@ class CreateSourceBloc extends Bloc { required DataRepository sourcesRepository, required DataRepository countriesRepository, required DataRepository languagesRepository, - }) : _sourcesRepository = sourcesRepository, - _countriesRepository = countriesRepository, - _languagesRepository = languagesRepository, - super(const CreateSourceState()) { + }) : _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + _languagesRepository = languagesRepository, + super(const CreateSourceState()) { on(_onDataLoaded); on(_onNameChanged); on(_onDescriptionChanged); @@ -31,6 +39,8 @@ class CreateSourceBloc extends Bloc { on(_onHeadquartersChanged); on(_onStatusChanged); on(_onSubmitted); + on<_FetchNextCountryPage>(_onFetchNextCountryPage); + on<_FetchNextLanguagePage>(_onFetchNextLanguagePage); } final DataRepository _sourcesRepository; @@ -66,34 +76,13 @@ class CreateSourceBloc extends Bloc { ), ); - // Start background fetching for all countries - while (state.countriesHasMore) { - final nextCountries = await _countriesRepository.readAll( - pagination: PaginationOptions(cursor: state.countriesCursor), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - countries: List.of(state.countries)..addAll(nextCountries.items), - countriesCursor: nextCountries.cursor, - countriesHasMore: nextCountries.hasMore, - ), - ); + // After the initial page is loaded, start background processes to + // fetch all remaining pages for countries and languages. + if (state.countriesHasMore) { + add(const _FetchNextCountryPage()); } - - // Start background fetching for all languages - while (state.languagesHasMore) { - final nextLanguages = await _languagesRepository.readAll( - pagination: PaginationOptions(cursor: state.languagesCursor), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - languages: List.of(state.languages)..addAll(nextLanguages.items), - languagesCursor: nextLanguages.cursor, - languagesHasMore: nextLanguages.hasMore, - ), - ); + if (state.languagesHasMore) { + add(const _FetchNextLanguagePage()); } } on HttpException catch (e) { emit(state.copyWith(status: CreateSourceStatus.failure, exception: e)); @@ -161,6 +150,83 @@ class CreateSourceBloc extends Bloc { ); } + // --- Background Data Fetching for 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. + // + // After the first page of items is loaded, a chain of events is initiated + // to progressively fetch all remaining pages. This process is throttled + // and runs in the background, ensuring the UI remains responsive while the + // full list of dropdown options is populated over time. + Future _onFetchNextCountryPage( + _FetchNextCountryPage event, + Emitter emit, + ) async { + if (!state.countriesHasMore || state.countriesIsLoadingMore) return; + + try { + emit(state.copyWith(countriesIsLoadingMore: true)); + + await Future.delayed(const Duration(milliseconds: 400)); + + final nextCountries = await _countriesRepository.readAll( + pagination: PaginationOptions(cursor: state.countriesCursor), + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(nextCountries.items), + countriesCursor: nextCountries.cursor, + countriesHasMore: nextCountries.hasMore, + countriesIsLoadingMore: false, + ), + ); + + if (nextCountries.hasMore) { + add(const _FetchNextCountryPage()); + } + } catch (e) { + emit(state.copyWith(countriesIsLoadingMore: false)); + // Optionally log the error without disrupting the user + } + } + + Future _onFetchNextLanguagePage( + _FetchNextLanguagePage event, + Emitter emit, + ) async { + if (!state.languagesHasMore || state.languagesIsLoadingMore) return; + + try { + emit(state.copyWith(languagesIsLoadingMore: true)); + + await Future.delayed(const Duration(milliseconds: 400)); + + final nextLanguages = await _languagesRepository.readAll( + pagination: PaginationOptions(cursor: state.languagesCursor), + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + languages: List.of(state.languages)..addAll(nextLanguages.items), + languagesCursor: nextLanguages.cursor, + languagesHasMore: nextLanguages.hasMore, + languagesIsLoadingMore: false, + ), + ); + + if (nextLanguages.hasMore) { + add(const _FetchNextLanguagePage()); + } + } catch (e) { + emit(state.copyWith(languagesIsLoadingMore: false)); + // Optionally log the error without disrupting the user + } + } + Future _onSubmitted( CreateSourceSubmitted event, Emitter emit, diff --git a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart index 8f12e49..da71832 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -8,6 +8,10 @@ import 'package:flutter/foundation.dart'; part 'edit_headline_event.dart'; part 'edit_headline_state.dart'; +final class _FetchNextCountryPage extends EditHeadlineEvent { + const _FetchNextCountryPage(); +} + const _searchDebounceDuration = Duration(milliseconds: 300); /// A BLoC to manage the state of editing a single headline. @@ -19,12 +23,12 @@ class EditHeadlineBloc extends Bloc { required DataRepository topicsRepository, required DataRepository countriesRepository, required String headlineId, - }) : _headlinesRepository = headlinesRepository, - _sourcesRepository = sourcesRepository, - _topicsRepository = topicsRepository, - _countriesRepository = countriesRepository, - _headlineId = headlineId, - super(const EditHeadlineState()) { + }) : _headlinesRepository = headlinesRepository, + _sourcesRepository = sourcesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, + _headlineId = headlineId, + super(const EditHeadlineState()) { on(_onLoaded); on(_onTitleChanged); on(_onExcerptChanged); @@ -35,6 +39,7 @@ class EditHeadlineBloc extends Bloc { on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); + on<_FetchNextCountryPage>(_onFetchNextCountryPage); } final DataRepository _headlinesRepository; @@ -87,19 +92,10 @@ class EditHeadlineBloc extends Bloc { ), ); - // Start background fetching for all countries - while (state.countriesHasMore) { - final nextCountries = await _countriesRepository.readAll( - pagination: PaginationOptions(cursor: state.countriesCursor), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - countries: List.of(state.countries)..addAll(nextCountries.items), - countriesCursor: nextCountries.cursor, - countriesHasMore: nextCountries.hasMore, - ), - ); + // After the initial page of countries is loaded, start a background + // process to fetch all remaining pages. + if (state.countriesHasMore) { + add(const _FetchNextCountryPage()); } } on HttpException catch (e) { emit(state.copyWith(status: EditHeadlineStatus.failure, exception: e)); @@ -201,6 +197,49 @@ class EditHeadlineBloc extends Bloc { ); } + // --- Background Data Fetching for 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. + // + // After the first page of items is loaded, a chain of events is initiated + // to progressively fetch all remaining pages. This process is throttled + // and runs in the background, ensuring the UI remains responsive while the + // full list of dropdown options is populated over time. + Future _onFetchNextCountryPage( + _FetchNextCountryPage event, + Emitter emit, + ) async { + if (!state.countriesHasMore || state.countriesIsLoadingMore) return; + + try { + emit(state.copyWith(countriesIsLoadingMore: true)); + + await Future.delayed(const Duration(milliseconds: 400)); + + final nextCountries = await _countriesRepository.readAll( + pagination: PaginationOptions(cursor: state.countriesCursor), + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(nextCountries.items), + countriesCursor: nextCountries.cursor, + countriesHasMore: nextCountries.hasMore, + countriesIsLoadingMore: false, + ), + ); + + if (nextCountries.hasMore) { + add(const _FetchNextCountryPage()); + } + } catch (e) { + emit(state.copyWith(countriesIsLoadingMore: false)); + // Optionally log the error without disrupting the user + } + } + Future _onSubmitted( EditHeadlineSubmitted event, Emitter emit, diff --git a/lib/content_management/bloc/edit_source/edit_source_bloc.dart b/lib/content_management/bloc/edit_source/edit_source_bloc.dart index 1c98adc..faeabe8 100644 --- a/lib/content_management/bloc/edit_source/edit_source_bloc.dart +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -8,6 +8,14 @@ import 'package:flutter/foundation.dart'; part 'edit_source_event.dart'; part 'edit_source_state.dart'; +final class _FetchNextCountryPage extends EditSourceEvent { + const _FetchNextCountryPage(); +} + +final class _FetchNextLanguagePage extends EditSourceEvent { + const _FetchNextLanguagePage(); +} + const _searchDebounceDuration = Duration(milliseconds: 300); /// A BLoC to manage the state of editing a single source. @@ -18,11 +26,11 @@ class EditSourceBloc extends Bloc { required DataRepository countriesRepository, required DataRepository languagesRepository, required String sourceId, - }) : _sourcesRepository = sourcesRepository, - _countriesRepository = countriesRepository, - _languagesRepository = languagesRepository, - _sourceId = sourceId, - super(const EditSourceState()) { + }) : _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + _languagesRepository = languagesRepository, + _sourceId = sourceId, + super(const EditSourceState()) { on(_onLoaded); on(_onNameChanged); on(_onDescriptionChanged); @@ -32,6 +40,8 @@ class EditSourceBloc extends Bloc { on(_onHeadquartersChanged); on(_onStatusChanged); on(_onSubmitted); + on<_FetchNextCountryPage>(_onFetchNextCountryPage); + on<_FetchNextLanguagePage>(_onFetchNextLanguagePage); } final DataRepository _sourcesRepository; @@ -90,34 +100,13 @@ class EditSourceBloc extends Bloc { ), ); - // Start background fetching for all countries - while (state.countriesHasMore) { - final nextCountries = await _countriesRepository.readAll( - pagination: PaginationOptions(cursor: state.countriesCursor), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - countries: List.of(state.countries)..addAll(nextCountries.items), - countriesCursor: nextCountries.cursor, - countriesHasMore: nextCountries.hasMore, - ), - ); + // After the initial page is loaded, start background processes to + // fetch all remaining pages for countries and languages. + if (state.countriesHasMore) { + add(const _FetchNextCountryPage()); } - - // Start background fetching for all languages - while (state.languagesHasMore) { - final nextLanguages = await _languagesRepository.readAll( - pagination: PaginationOptions(cursor: state.languagesCursor), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - languages: List.of(state.languages)..addAll(nextLanguages.items), - languagesCursor: nextLanguages.cursor, - languagesHasMore: nextLanguages.hasMore, - ), - ); + if (state.languagesHasMore) { + add(const _FetchNextLanguagePage()); } } on HttpException catch (e) { emit(state.copyWith(status: EditSourceStatus.failure, exception: e)); @@ -205,6 +194,83 @@ class EditSourceBloc extends Bloc { ); } + // --- Background Data Fetching for 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. + // + // After the first page of items is loaded, a chain of events is initiated + // to progressively fetch all remaining pages. This process is throttled + // and runs in the background, ensuring the UI remains responsive while the + // full list of dropdown options is populated over time. + Future _onFetchNextCountryPage( + _FetchNextCountryPage event, + Emitter emit, + ) async { + if (!state.countriesHasMore || state.countriesIsLoadingMore) return; + + try { + emit(state.copyWith(countriesIsLoadingMore: true)); + + await Future.delayed(const Duration(milliseconds: 400)); + + final nextCountries = await _countriesRepository.readAll( + pagination: PaginationOptions(cursor: state.countriesCursor), + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(nextCountries.items), + countriesCursor: nextCountries.cursor, + countriesHasMore: nextCountries.hasMore, + countriesIsLoadingMore: false, + ), + ); + + if (nextCountries.hasMore) { + add(const _FetchNextCountryPage()); + } + } catch (e) { + emit(state.copyWith(countriesIsLoadingMore: false)); + // Optionally log the error without disrupting the user + } + } + + Future _onFetchNextLanguagePage( + _FetchNextLanguagePage event, + Emitter emit, + ) async { + if (!state.languagesHasMore || state.languagesIsLoadingMore) return; + + try { + emit(state.copyWith(languagesIsLoadingMore: true)); + + await Future.delayed(const Duration(milliseconds: 400)); + + final nextLanguages = await _languagesRepository.readAll( + pagination: PaginationOptions(cursor: state.languagesCursor), + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + languages: List.of(state.languages)..addAll(nextLanguages.items), + languagesCursor: nextLanguages.cursor, + languagesHasMore: nextLanguages.hasMore, + languagesIsLoadingMore: false, + ), + ); + + if (nextLanguages.hasMore) { + add(const _FetchNextLanguagePage()); + } + } catch (e) { + emit(state.copyWith(languagesIsLoadingMore: false)); + // Optionally log the error without disrupting the user + } + } + Future _onSubmitted( EditSourceSubmitted event, Emitter emit, diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index 57a5087..d6408e1 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -219,6 +219,9 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { decoration: InputDecoration( labelText: l10n.countryName, border: const OutlineInputBorder(), + helperText: state.countriesIsLoadingMore + ? l10n.loadingFullList + : null, ), items: [ DropdownMenuItem(value: null, child: Text(l10n.none)), @@ -245,9 +248,11 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { ), ), ], - onChanged: (value) => context - .read() - .add(CreateHeadlineCountryChanged(value)), + onChanged: state.countriesIsLoadingMore + ? null + : (value) => context + .read() + .add(CreateHeadlineCountryChanged(value)), ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField(