From c20e7c1ea4a161d9e74346cc9eacf67fddf25b8a Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 19:20:39 +0100 Subject: [PATCH 01/22] refactor(content_management): improve error handling in CreateSourceBloc - Add HttpException handling to provide more specific error information - Introduce UnknownException for unexpected errors - Update state.copyWith calls to include exception details --- .../bloc/create_source/create_source_bloc.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 312f505c..f447732f 100644 --- a/lib/content_management/bloc/create_source/create_source_bloc.dart +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -60,8 +60,15 @@ class CreateSourceBloc extends Bloc { languages: languages, ), ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateSourceStatus.failure, exception: e)); } catch (e) { - emit(state.copyWith(status: CreateSourceStatus.failure)); + emit( + state.copyWith( + status: CreateSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); } } From ca6e32080e5a7e4fd4aaf4401b63731e236e94ab Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 19:29:28 +0100 Subject: [PATCH 02/22] feat(content_management): add pagination fields to headline states Refactors `CreateHeadlineState` and `EditHeadlineState` to support paginated loading of countries. --- .../create_headline/create_headline_state.dart | 15 +++++++++++++++ .../bloc/edit_headline/edit_headline_state.dart | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/content_management/bloc/create_headline/create_headline_state.dart b/lib/content_management/bloc/create_headline/create_headline_state.dart index 0739db1c..c914953e 100644 --- a/lib/content_management/bloc/create_headline/create_headline_state.dart +++ b/lib/content_management/bloc/create_headline/create_headline_state.dart @@ -32,6 +32,9 @@ final class CreateHeadlineState extends Equatable { this.sources = const [], this.topics = const [], this.countries = const [], + this.countriesHasMore = true, + this.countriesCursor, + this.countrySearchTerm = '', this.contentStatus = ContentStatus.active, this.exception, this.createdHeadline, @@ -48,6 +51,9 @@ final class CreateHeadlineState extends Equatable { final List sources; final List topics; final List countries; + final bool countriesHasMore; + final String? countriesCursor; + final String countrySearchTerm; final ContentStatus contentStatus; final HttpException? exception; final Headline? createdHeadline; @@ -74,6 +80,9 @@ final class CreateHeadlineState extends Equatable { List? sources, List? topics, List? countries, + bool? countriesHasMore, + String? countriesCursor, + String? countrySearchTerm, ContentStatus? contentStatus, HttpException? exception, Headline? createdHeadline, @@ -90,6 +99,9 @@ final class CreateHeadlineState extends Equatable { sources: sources ?? this.sources, topics: topics ?? this.topics, countries: countries ?? this.countries, + countriesHasMore: countriesHasMore ?? this.countriesHasMore, + countriesCursor: countriesCursor ?? this.countriesCursor, + countrySearchTerm: countrySearchTerm ?? this.countrySearchTerm, contentStatus: contentStatus ?? this.contentStatus, exception: exception, createdHeadline: createdHeadline ?? this.createdHeadline, @@ -109,6 +121,9 @@ final class CreateHeadlineState extends Equatable { sources, topics, countries, + countriesHasMore, + countriesCursor, + countrySearchTerm, contentStatus, exception, createdHeadline, diff --git a/lib/content_management/bloc/edit_headline/edit_headline_state.dart b/lib/content_management/bloc/edit_headline/edit_headline_state.dart index fba983f4..11e5b074 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_state.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_state.dart @@ -33,6 +33,9 @@ final class EditHeadlineState extends Equatable { this.sources = const [], this.topics = const [], this.countries = const [], + this.countriesHasMore = true, + this.countriesCursor, + this.countrySearchTerm = '', this.contentStatus = ContentStatus.active, this.exception, this.updatedHeadline, @@ -50,6 +53,9 @@ final class EditHeadlineState extends Equatable { final List sources; final List topics; final List countries; + final bool countriesHasMore; + final String? countriesCursor; + final String countrySearchTerm; final ContentStatus contentStatus; final HttpException? exception; final Headline? updatedHeadline; @@ -77,6 +83,9 @@ final class EditHeadlineState extends Equatable { List? sources, List? topics, List? countries, + bool? countriesHasMore, + String? countriesCursor, + String? countrySearchTerm, ContentStatus? contentStatus, HttpException? exception, Headline? updatedHeadline, @@ -94,6 +103,9 @@ final class EditHeadlineState extends Equatable { sources: sources ?? this.sources, topics: topics ?? this.topics, countries: countries ?? this.countries, + countriesHasMore: countriesHasMore ?? this.countriesHasMore, + countriesCursor: countriesCursor ?? this.countriesCursor, + countrySearchTerm: countrySearchTerm ?? this.countrySearchTerm, contentStatus: contentStatus ?? this.contentStatus, exception: exception, updatedHeadline: updatedHeadline ?? this.updatedHeadline, @@ -114,6 +126,9 @@ final class EditHeadlineState extends Equatable { sources, topics, countries, + countriesHasMore, + countriesCursor, + countrySearchTerm, contentStatus, exception, updatedHeadline, From 2612cb59350bbf10a36f7883c78838cd18a73e90 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 19:31:55 +0100 Subject: [PATCH 03/22] feat(content_management): add pagination events to headline blocs Introduces new events to `CreateHeadlineBloc` and `EditHeadlineBloc` to support paginated and searchable country dropdowns. - `CountrySearchChanged`: Dispatched when the user types in the search field for countries. - `LoadMoreCountriesRequested`: Dispatched when the user scrolls to the end of the country list, requesting the next page of data. --- .../create_headline/create_headline_event.dart | 14 ++++++++++++++ .../bloc/edit_headline/edit_headline_event.dart | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/content_management/bloc/create_headline/create_headline_event.dart b/lib/content_management/bloc/create_headline/create_headline_event.dart index 655eae97..b2dd90df 100644 --- a/lib/content_management/bloc/create_headline/create_headline_event.dart +++ b/lib/content_management/bloc/create_headline/create_headline_event.dart @@ -83,3 +83,17 @@ final class CreateHeadlineStatusChanged extends CreateHeadlineEvent { final class CreateHeadlineSubmitted extends CreateHeadlineEvent { const CreateHeadlineSubmitted(); } + +/// Event for when the country search term is changed. +final class CreateHeadlineCountrySearchChanged extends CreateHeadlineEvent { + const CreateHeadlineCountrySearchChanged(this.searchTerm); + final String searchTerm; + @override + List get props => [searchTerm]; +} + +/// Event to request loading more countries. +final class CreateHeadlineLoadMoreCountriesRequested + extends CreateHeadlineEvent { + const CreateHeadlineLoadMoreCountriesRequested(); +} diff --git a/lib/content_management/bloc/edit_headline/edit_headline_event.dart b/lib/content_management/bloc/edit_headline/edit_headline_event.dart index 6bad7bdd..f07256d6 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_event.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_event.dart @@ -83,3 +83,16 @@ final class EditHeadlineStatusChanged extends EditHeadlineEvent { final class EditHeadlineSubmitted extends EditHeadlineEvent { const EditHeadlineSubmitted(); } + +/// Event for when the country search term is changed. +final class EditHeadlineCountrySearchChanged extends EditHeadlineEvent { + const EditHeadlineCountrySearchChanged(this.searchTerm); + final String searchTerm; + @override + List get props => [searchTerm]; +} + +/// Event to request loading more countries. +final class EditHeadlineLoadMoreCountriesRequested extends EditHeadlineEvent { + const EditHeadlineLoadMoreCountriesRequested(); +} From b15d07cbec6767849c50f13c5b4d964018223e5d Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 19:40:04 +0100 Subject: [PATCH 04/22] feat(content_management): implement search and pagination for countries - Add debounce functionality for country search - Implement load more countries functionality - Refactor country data loading process - Import bloc_concurrency package for concurrency management --- .../create_headline/create_headline_bloc.dart | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 7a053c58..7ad29374 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; @@ -8,6 +9,8 @@ import 'package:uuid/uuid.dart'; part 'create_headline_event.dart'; part 'create_headline_state.dart'; +const _searchDebounceDuration = Duration(milliseconds: 300); + /// A BLoC to manage the state of creating a new headline. class CreateHeadlineBloc extends Bloc { @@ -32,6 +35,12 @@ class CreateHeadlineBloc on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); + on( + _onCountrySearchChanged, + transformer: debounce(_searchDebounceDuration), + ); + on( + _onLoadMoreCountriesRequested); } final DataRepository _headlinesRepository; @@ -46,27 +55,23 @@ class CreateHeadlineBloc ) async { emit(state.copyWith(status: CreateHeadlineStatus.loading)); try { - final [ - sourcesResponse, - topicsResponse, - countriesResponse, - ] = await Future.wait([ + final [sourcesResponse, topicsResponse] = await Future.wait([ _sourcesRepository.readAll( sort: [const SortOption('updatedAt', SortOrder.desc)], ), _topicsRepository.readAll( sort: [const SortOption('updatedAt', SortOrder.desc)], ), - _countriesRepository.readAll( - sort: [const SortOption('name', SortOrder.asc)], - ), ]); final sources = (sourcesResponse as PaginatedResponse).items; final topics = (topicsResponse as PaginatedResponse).items; - final countries = - (countriesResponse as PaginatedResponse).items; + final countriesResponse = await _countriesRepository.readAll( + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + final countries = countriesResponse.items; emit( state.copyWith( status: CreateHeadlineStatus.initial, From 38614f19974ba1c4e5470c46c381fd94dbac8d05 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:14:04 +0100 Subject: [PATCH 05/22] feat(content_management): add country search and pagination - Implement country search functionality in CreateHeadlineBloc - Add pagination support for loading more countries - Update CreateHeadlineState to include country search term, cursor, and hasMore flag --- .../create_headline/create_headline_bloc.dart | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) 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 7ad29374..d026b5d5 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -71,13 +71,14 @@ class CreateHeadlineBloc sort: [const SortOption('name', SortOrder.asc)], ) as PaginatedResponse; - final countries = countriesResponse.items; emit( state.copyWith( status: CreateHeadlineStatus.initial, sources: sources, topics: topics, - countries: countries, + countries: countriesResponse.items, + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, ), ); } on HttpException catch (e) { @@ -194,4 +195,66 @@ class CreateHeadlineBloc ); } } + + Future _onCountrySearchChanged( + CreateHeadlineCountrySearchChanged event, + Emitter emit, + ) async { + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: {'name': event.searchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: countriesResponse.items, + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateHeadlineStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: CreateHeadlineStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onLoadMoreCountriesRequested( + CreateHeadlineLoadMoreCountriesRequested event, + Emitter emit, + ) async { + if (!state.countriesHasMore) return; + + try { + final countriesResponse = await _countriesRepository.readAll( + cursor: state.countriesCursor, + filter: {'name': state.countrySearchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(countriesResponse.items), + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateHeadlineStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: CreateHeadlineStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } } From 204b5369c4b0ae01e1b21617a77619a404ad84d3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:14:22 +0100 Subject: [PATCH 06/22] feat(content_management): import bloc_concurrency for enhanced state management - Add bloc_concurrency package to improve handling of concurrent events --- .../bloc/edit_headline/edit_headline_bloc.dart | 1 + 1 file changed, 1 insertion(+) 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 02a64f45..ad0fadb5 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; From 4bb564b5027768c74d4b490fbbb8bd381d6ad667 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:15:28 +0100 Subject: [PATCH 07/22] feat(content_management): implement paginated logic in headline BLoCs Refactors `CreateHeadlineBloc` and `EditHeadlineBloc` to support paginated and searchable country data fetching. - Updates `_onDataLoaded` and `_onLoaded` to fetch only the first page of countries initially. - Implements `_onCountrySearchChanged` to fetch a new list of countries based on a search term, with a debounce to prevent excessive API calls. - Implements `_onLoadMoreCountriesRequested` to fetch and append the next page of countries to the existing list. - Updates state with pagination cursor and `hasMore` flag from the repository response. --- .../edit_headline/edit_headline_bloc.dart | 91 ++++++++++++++++--- 1 file changed, 80 insertions(+), 11 deletions(-) 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 ad0fadb5..f5c8fc11 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,8 @@ import 'package:flutter/foundation.dart'; part 'edit_headline_event.dart'; part 'edit_headline_state.dart'; +const _searchDebounceDuration = Duration(milliseconds: 300); + /// A BLoC to manage the state of editing a single headline. class EditHeadlineBloc extends Bloc { /// {@macro edit_headline_bloc} @@ -33,6 +35,13 @@ class EditHeadlineBloc extends Bloc { on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); + on( + _onCountrySearchChanged, + transformer: debounce(_searchDebounceDuration), + ); + on( + _onLoadMoreCountriesRequested, + ); } final DataRepository _headlinesRepository; @@ -47,12 +56,8 @@ class EditHeadlineBloc extends Bloc { ) async { emit(state.copyWith(status: EditHeadlineStatus.loading)); try { - final [ - headlineResponse, - sourcesResponse, - topicsResponse, - countriesResponse, - ] = await Future.wait([ + final [headlineResponse, sourcesResponse, topicsResponse] = + await Future.wait([ _headlinesRepository.read(id: _headlineId), _sourcesRepository.readAll( sort: [const SortOption('updatedAt', SortOrder.desc)], @@ -60,15 +65,15 @@ class EditHeadlineBloc extends Bloc { _topicsRepository.readAll( sort: [const SortOption('updatedAt', SortOrder.desc)], ), - _countriesRepository.readAll( - sort: [const SortOption('name', SortOrder.asc)], - ), ]); final headline = headlineResponse as Headline; final sources = (sourcesResponse as PaginatedResponse).items; final topics = (topicsResponse as PaginatedResponse).items; - final countries = (countriesResponse as PaginatedResponse).items; + + final countriesResponse = await _countriesRepository.readAll( + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; emit( state.copyWith( @@ -83,7 +88,9 @@ class EditHeadlineBloc extends Bloc { eventCountry: () => headline.eventCountry, sources: sources, topics: topics, - countries: countries, + countries: countriesResponse.items, + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, contentStatus: headline.status, ), ); @@ -238,4 +245,66 @@ class EditHeadlineBloc extends Bloc { ); } } + + Future _onCountrySearchChanged( + EditHeadlineCountrySearchChanged event, + Emitter emit, + ) async { + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: {'name': event.searchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: countriesResponse.items, + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: EditHeadlineStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onLoadMoreCountriesRequested( + EditHeadlineLoadMoreCountriesRequested event, + Emitter emit, + ) async { + if (!state.countriesHasMore) return; + + try { + final countriesResponse = await _countriesRepository.readAll( + cursor: state.countriesCursor, + filter: {'name': state.countrySearchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(countriesResponse.items), + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: EditHeadlineStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } } From 9aa277e8df7663813c96ca1fb7a9b30e0f498146 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:17:19 +0100 Subject: [PATCH 08/22] feat(shared): create searchable paginated dropdown form field Introduces a new reusable `SearchableDropdownFormField` widget. This generic component provides a user-friendly and performant way to select an item from a large, paginated data set. - Displays a form field that, when tapped, opens a modal dialog. - The modal contains a search input to filter the list. - The list is paginated, fetching more items as the user scrolls. - The widget is generic (``) and uses builder functions for items, making it adaptable for various data types (e.g., Country, Language). --- .../searchable_dropdown_form_field.dart | 210 ++++++++++++++++++ lib/shared/widgets/widgets.dart | 1 + 2 files changed, 211 insertions(+) create mode 100644 lib/shared/widgets/searchable_dropdown_form_field.dart diff --git a/lib/shared/widgets/searchable_dropdown_form_field.dart b/lib/shared/widgets/searchable_dropdown_form_field.dart new file mode 100644 index 00000000..d5887ee5 --- /dev/null +++ b/lib/shared/widgets/searchable_dropdown_form_field.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// A generic type for the builder function that creates list items in the +/// searchable dropdown. +typedef SearchableDropdownItemBuilder = Widget Function( + BuildContext context, + T item, +); + +/// A generic type for the builder function that creates the widget to display +/// the selected item within the form field. +typedef SearchableDropdownSelectedItemBuilder = Widget Function( + BuildContext context, + T item, +); + +/// A form field that allows users to select an item from a searchable, +/// paginated list displayed in a modal dialog. +/// +/// This widget is generic and can be used for any type [T]. It requires +/// builders for constructing the list items and the selected item display, +/// as well as callbacks to handle searching and pagination. +class SearchableDropdownFormField extends FormField { + /// {@macro searchable_dropdown_form_field} + SearchableDropdownFormField({ + required List items, + required ValueChanged onChanged, + required ValueChanged onSearchChanged, + required VoidCallback onLoadMore, + required SearchableDropdownItemBuilder itemBuilder, + required SearchableDropdownSelectedItemBuilder selectedItemBuilder, + required bool hasMore, + bool? isLoading, + super.key, + T? initialValue, + String? labelText, + String? searchHintText, + String? noItemsFoundText, + super.onSaved, + super.validator, + super.autovalidateMode = AutovalidateMode.onUserInteraction, + }) : super( + initialValue: initialValue, + builder: (FormFieldState state) { + // This is the widget that will be displayed in the form. + // It looks like a text field but opens a dialog on tap. + return InkWell( + onTap: () async { + final selectedItem = await showDialog( + context: state.context, + builder: (context) => _SearchableSelectionDialog( + items: items, + onSearchChanged: onSearchChanged, + onLoadMore: onLoadMore, + itemBuilder: itemBuilder, + hasMore: hasMore, + isLoading: isLoading ?? false, + searchHintText: searchHintText, + noItemsFoundText: noItemsFoundText, + ), + ); + + if (selectedItem != null) { + state.didChange(selectedItem); + onChanged(selectedItem); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: labelText, + border: const OutlineInputBorder(), + errorText: state.errorText, + suffixIcon: const Icon(Icons.arrow_drop_down), + ), + child: state.value == null + ? const SizedBox(height: 20) // To maintain field height + : selectedItemBuilder(state.context, state.value as T), + ), + ); + }, + ); +} + +/// The modal dialog that contains the searchable and paginated list. +class _SearchableSelectionDialog extends StatefulWidget { + const _SearchableSelectionDialog({ + required this.items, + required this.onSearchChanged, + required this.onLoadMore, + required this.itemBuilder, + required this.hasMore, + required this.isLoading, + this.searchHintText, + this.noItemsFoundText, + super.key, + }); + + final List items; + final ValueChanged onSearchChanged; + final VoidCallback onLoadMore; + final SearchableDropdownItemBuilder itemBuilder; + final bool hasMore; + final bool isLoading; + final String? searchHintText; + final String? noItemsFoundText; + + @override + State<_SearchableSelectionDialog> createState() => + _SearchableSelectionDialogState(); +} + +class _SearchableSelectionDialogState + extends State<_SearchableSelectionDialog> { + final _scrollController = ScrollController(); + final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _searchController.addListener(() { + widget.onSearchChanged(_searchController.text); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) { + widget.onLoadMore(); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + // Add a small buffer to trigger before reaching the absolute bottom. + return currentScroll >= (maxScroll * 0.9); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: SizedBox( + width: 400, + height: 600, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: widget.searchHintText ?? 'Search...', + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.md), + Expanded( + child: _buildList(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildList() { + if (widget.isLoading && widget.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (widget.items.isEmpty) { + return Center( + child: Text(widget.noItemsFoundText ?? 'No items found.'), + ); + } + + return ListView.builder( + controller: _scrollController, + itemCount: + widget.hasMore ? widget.items.length + 1 : widget.items.length, + itemBuilder: (context, index) { + if (index >= widget.items.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Center(child: CircularProgressIndicator()), + ); + } + final item = widget.items[index]; + return InkWell( + onTap: () => Navigator.of(context).pop(item), + child: widget.itemBuilder(context, item), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index c108713d..18a77b20 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,2 +1,3 @@ export 'country_dropdown_form_field.dart'; export 'language_dropdown_form_field.dart'; +export 'searchable_dropdown_form_field.dart'; From b784d403a771f9f8eaf974a15a5aa4526d0d9dac Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:19:05 +0100 Subject: [PATCH 09/22] feat(content_management): integrate searchable country dropdown Replaces the standard `CountryDropdownFormField` with the new `SearchableDropdownFormField` in both the `CreateHeadlinePage` and `EditHeadlinePage`. This change connects the UI to the new pagination and search logic in the `CreateHeadlineBloc` and `EditHeadlineBloc`, allowing users to efficiently search and load countries from a large dataset. The dropdown now displays the country's flag alongside its name for an improved user experience. --- .../view/create_headline_page.dart | 45 ++++++++++++++++++- .../view/edit_headline_page.dart | 45 ++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index 204da4e3..83110147 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -214,13 +214,54 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { .add(CreateHeadlineTopicChanged(value)), ), const SizedBox(height: AppSpacing.lg), - CountryDropdownFormField( + SearchableDropdownFormField( labelText: l10n.countryName, - countries: state.countries, + items: state.countries, initialValue: state.eventCountry, + hasMore: state.countriesHasMore, + isLoading: state.status == CreateHeadlineStatus.loading, onChanged: (value) => context .read() .add(CreateHeadlineCountryChanged(value)), + onSearchChanged: (value) => context + .read() + .add(CreateHeadlineCountrySearchChanged(value)), + onLoadMore: () => context.read().add( + const CreateHeadlineLoadMoreCountriesRequested(), + ), + itemBuilder: (context, country) { + return ListTile( + leading: SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + title: Text(country.name), + ); + }, + selectedItemBuilder: (context, country) { + return 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(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 9387a253..2b9cc858 100644 --- a/lib/content_management/view/edit_headline_page.dart +++ b/lib/content_management/view/edit_headline_page.dart @@ -282,13 +282,54 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { .add(EditHeadlineTopicChanged(value)), ), const SizedBox(height: AppSpacing.lg), - CountryDropdownFormField( + SearchableDropdownFormField( labelText: l10n.countryName, - countries: state.countries, + items: state.countries, initialValue: selectedCountry, + hasMore: state.countriesHasMore, + isLoading: state.status == EditHeadlineStatus.loading, onChanged: (value) => context .read() .add(EditHeadlineCountryChanged(value)), + onSearchChanged: (value) => context + .read() + .add(EditHeadlineCountrySearchChanged(value)), + onLoadMore: () => context.read().add( + const EditHeadlineLoadMoreCountriesRequested(), + ), + itemBuilder: (context, country) { + return ListTile( + leading: SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + title: Text(country.name), + ); + }, + selectedItemBuilder: (context, country) { + return 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(height: AppSpacing.lg), DropdownButtonFormField( From 09ba20f4b7f83a3fb4585f63a21f7a76fc4c8eb5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:20:19 +0100 Subject: [PATCH 10/22] feat(content_management): add pagination fields to source states Refactors `CreateSourceState` and `EditSourceState` to support paginated loading of both countries and languages. Replaces the simple lists with a set of fields to manage pagination state for each entity: `hasMore`, `cursor`, and `searchTerm`. This is the first step towards implementing searchable, paginated dropdowns for country and language selection in the source forms. --- .../create_source/create_source_state.dart | 34 +++++++++++++++++-- .../bloc/edit_source/edit_source_state.dart | 30 ++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/lib/content_management/bloc/create_source/create_source_state.dart b/lib/content_management/bloc/create_source/create_source_state.dart index d86c2c52..783a200b 100644 --- a/lib/content_management/bloc/create_source/create_source_state.dart +++ b/lib/content_management/bloc/create_source/create_source_state.dart @@ -30,7 +30,13 @@ final class CreateSourceState extends Equatable { this.language, this.headquarters, this.countries = const [], + this.countriesHasMore = true, + this.countriesCursor, + this.countrySearchTerm = '', this.languages = const [], + this.languagesHasMore = true, + this.languagesCursor, + this.languageSearchTerm = '', this.contentStatus = ContentStatus.active, this.exception, this.createdSource, @@ -44,7 +50,13 @@ final class CreateSourceState extends Equatable { final Language? language; final Country? headquarters; final List countries; + final bool countriesHasMore; + final String? countriesCursor; + final String countrySearchTerm; final List languages; + final bool languagesHasMore; + final String? languagesCursor; + final String languageSearchTerm; final ContentStatus contentStatus; final HttpException? exception; final Source? createdSource; @@ -67,7 +79,13 @@ final class CreateSourceState extends Equatable { ValueGetter? language, ValueGetter? headquarters, List? countries, + bool? countriesHasMore, + String? countriesCursor, + String? countrySearchTerm, List? languages, + bool? languagesHasMore, + String? languagesCursor, + String? languageSearchTerm, ContentStatus? contentStatus, HttpException? exception, Source? createdSource, @@ -81,7 +99,13 @@ final class CreateSourceState extends Equatable { language: language != null ? language() : this.language, headquarters: headquarters != null ? headquarters() : this.headquarters, countries: countries ?? this.countries, + countriesHasMore: countriesHasMore ?? this.countriesHasMore, + countriesCursor: countriesCursor ?? this.countriesCursor, + countrySearchTerm: countrySearchTerm ?? this.countrySearchTerm, languages: languages ?? this.languages, + languagesHasMore: languagesHasMore ?? this.languagesHasMore, + languagesCursor: languagesCursor ?? this.languagesCursor, + languageSearchTerm: languageSearchTerm ?? this.languageSearchTerm, contentStatus: contentStatus ?? this.contentStatus, exception: exception, createdSource: createdSource ?? this.createdSource, @@ -97,8 +121,14 @@ final class CreateSourceState extends Equatable { sourceType, language, headquarters, - countries, - languages, + countries, + countriesHasMore, + countriesCursor, + countrySearchTerm, + languages, + languagesHasMore, + languagesCursor, + languageSearchTerm, contentStatus, exception, createdSource, diff --git a/lib/content_management/bloc/edit_source/edit_source_state.dart b/lib/content_management/bloc/edit_source/edit_source_state.dart index 1c65c6ae..f4e3281f 100644 --- a/lib/content_management/bloc/edit_source/edit_source_state.dart +++ b/lib/content_management/bloc/edit_source/edit_source_state.dart @@ -30,7 +30,13 @@ final class EditSourceState extends Equatable { this.language, this.headquarters, this.countries = const [], + this.countriesHasMore = true, + this.countriesCursor, + this.countrySearchTerm = '', this.languages = const [], + this.languagesHasMore = true, + this.languagesCursor, + this.languageSearchTerm = '', this.contentStatus = ContentStatus.active, this.exception, this.updatedSource, @@ -45,7 +51,13 @@ final class EditSourceState extends Equatable { final Language? language; final Country? headquarters; final List countries; + final bool countriesHasMore; + final String? countriesCursor; + final String countrySearchTerm; final List languages; + final bool languagesHasMore; + final String? languagesCursor; + final String languageSearchTerm; final ContentStatus contentStatus; final HttpException? exception; final Source? updatedSource; @@ -69,7 +81,13 @@ final class EditSourceState extends Equatable { ValueGetter? language, ValueGetter? headquarters, List? countries, + bool? countriesHasMore, + String? countriesCursor, + String? countrySearchTerm, List? languages, + bool? languagesHasMore, + String? languagesCursor, + String? languageSearchTerm, ContentStatus? contentStatus, HttpException? exception, Source? updatedSource, @@ -84,7 +102,13 @@ final class EditSourceState extends Equatable { language: language != null ? language() : this.language, headquarters: headquarters != null ? headquarters() : this.headquarters, countries: countries ?? this.countries, + countriesHasMore: countriesHasMore ?? this.countriesHasMore, + countriesCursor: countriesCursor ?? this.countriesCursor, + countrySearchTerm: countrySearchTerm ?? this.countrySearchTerm, languages: languages ?? this.languages, + languagesHasMore: languagesHasMore ?? this.languagesHasMore, + languagesCursor: languagesCursor ?? this.languagesCursor, + languageSearchTerm: languageSearchTerm ?? this.languageSearchTerm, contentStatus: contentStatus ?? this.contentStatus, exception: exception, updatedSource: updatedSource ?? this.updatedSource, @@ -102,7 +126,13 @@ final class EditSourceState extends Equatable { language, headquarters, countries, + countriesHasMore, + countriesCursor, + countrySearchTerm, languages, + languagesHasMore, + languagesCursor, + languageSearchTerm, contentStatus, exception, updatedSource, From 517766bad62c839f3d96a44b370c29344ded276b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:21:49 +0100 Subject: [PATCH 11/22] feat(content_management): add pagination events to source blocs Introduces new events to `CreateSourceBloc` and `EditSourceBloc` to support paginated and searchable dropdowns for both countries and languages. - `CountrySearchChanged` / `LanguageSearchChanged`: Dispatched when the user types in the search field. - `LoadMoreCountriesRequested` / `LoadMoreLanguagesRequested`: Dispatched when the user scrolls to the end of the list, requesting the next page. --- .../create_source/create_source_event.dart | 26 +++++++++++++++++++ .../bloc/edit_source/edit_source_event.dart | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/content_management/bloc/create_source/create_source_event.dart b/lib/content_management/bloc/create_source/create_source_event.dart index e4c4f566..d15f2f4e 100644 --- a/lib/content_management/bloc/create_source/create_source_event.dart +++ b/lib/content_management/bloc/create_source/create_source_event.dart @@ -75,3 +75,29 @@ final class CreateSourceStatusChanged extends CreateSourceEvent { final class CreateSourceSubmitted extends CreateSourceEvent { const CreateSourceSubmitted(); } + +/// Event for when the country search term is changed. +final class CreateSourceCountrySearchChanged extends CreateSourceEvent { + const CreateSourceCountrySearchChanged(this.searchTerm); + final String searchTerm; + @override + List get props => [searchTerm]; +} + +/// Event to request loading more countries. +final class CreateSourceLoadMoreCountriesRequested extends CreateSourceEvent { + const CreateSourceLoadMoreCountriesRequested(); +} + +/// Event for when the language search term is changed. +final class CreateSourceLanguageSearchChanged extends CreateSourceEvent { + const CreateSourceLanguageSearchChanged(this.searchTerm); + final String searchTerm; + @override + List get props => [searchTerm]; +} + +/// Event to request loading more languages. +final class CreateSourceLoadMoreLanguagesRequested extends CreateSourceEvent { + const CreateSourceLoadMoreLanguagesRequested(); +} diff --git a/lib/content_management/bloc/edit_source/edit_source_event.dart b/lib/content_management/bloc/edit_source/edit_source_event.dart index 8620d9a5..b68ec430 100644 --- a/lib/content_management/bloc/edit_source/edit_source_event.dart +++ b/lib/content_management/bloc/edit_source/edit_source_event.dart @@ -86,3 +86,29 @@ final class EditSourceStatusChanged extends EditSourceEvent { final class EditSourceSubmitted extends EditSourceEvent { const EditSourceSubmitted(); } + +/// Event for when the country search term is changed. +final class EditSourceCountrySearchChanged extends EditSourceEvent { + const EditSourceCountrySearchChanged(this.searchTerm); + final String searchTerm; + @override + List get props => [searchTerm]; +} + +/// Event to request loading more countries. +final class EditSourceLoadMoreCountriesRequested extends EditSourceEvent { + const EditSourceLoadMoreCountriesRequested(); +} + +/// Event for when the language search term is changed. +final class EditSourceLanguageSearchChanged extends EditSourceEvent { + const EditSourceLanguageSearchChanged(this.searchTerm); + final String searchTerm; + @override + List get props => [searchTerm]; +} + +/// Event to request loading more languages. +final class EditSourceLoadMoreLanguagesRequested extends EditSourceEvent { + const EditSourceLoadMoreLanguagesRequested(); +} From 76fd1af234caa642d8dda907175b6d1fa42ced49 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:23:13 +0100 Subject: [PATCH 12/22] feat(content_management): implement paginated logic in CreateSourceBloc Refactors `CreateSourceBloc` to support paginated and searchable data fetching for both countries and languages. - Updates `_onDataLoaded` to fetch only the first page of data. - Implements handlers for searching and loading more data for both countries and languages, with a debounce for search inputs. - Updates state with pagination cursors and `hasMore` flags from the repository responses. --- .../create_source/create_source_bloc.dart | 127 ++++++++++++++++-- 1 file changed, 119 insertions(+), 8 deletions(-) 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 f447732f..b4e7c370 100644 --- a/lib/content_management/bloc/create_source/create_source_bloc.dart +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; @@ -8,6 +9,8 @@ import 'package:uuid/uuid.dart'; part 'create_source_event.dart'; part 'create_source_state.dart'; +const _searchDebounceDuration = Duration(milliseconds: 300); + /// A BLoC to manage the state of creating a new source. class CreateSourceBloc extends Bloc { /// {@macro create_source_bloc} @@ -28,6 +31,20 @@ class CreateSourceBloc extends Bloc { on(_onHeadquartersChanged); on(_onStatusChanged); on(_onSubmitted); + on( + _onCountrySearchChanged, + transformer: debounce(_searchDebounceDuration), + ); + on( + _onLoadMoreCountriesRequested, + ); + on( + _onLanguageSearchChanged, + transformer: debounce(_searchDebounceDuration), + ); + on( + _onLoadMoreLanguagesRequested, + ); } final DataRepository _sourcesRepository; @@ -41,23 +58,24 @@ class CreateSourceBloc extends Bloc { ) async { emit(state.copyWith(status: CreateSourceStatus.loading)); try { - final [countriesResponse, languagesResponse] = await Future.wait([ + final [countriesPaginated, languagesPaginated] = await Future.wait([ _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ), + ) as Future>, _languagesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ), + ) as Future>, ]); - final countries = (countriesResponse as PaginatedResponse).items; - final languages = (languagesResponse as PaginatedResponse).items; - emit( state.copyWith( status: CreateSourceStatus.initial, - countries: countries, - languages: languages, + countries: countriesPaginated.items, + countriesCursor: countriesPaginated.cursor, + countriesHasMore: countriesPaginated.hasMore, + languages: languagesPaginated.items, + languagesCursor: languagesPaginated.cursor, + languagesHasMore: languagesPaginated.hasMore, ), ); } on HttpException catch (e) { @@ -166,4 +184,97 @@ class CreateSourceBloc extends Bloc { ); } } + + Future _onCountrySearchChanged( + CreateSourceCountrySearchChanged event, + Emitter emit, + ) async { + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: {'name': event.searchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: countriesResponse.items, + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateSourceStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onLoadMoreCountriesRequested( + CreateSourceLoadMoreCountriesRequested event, + Emitter emit, + ) async { + if (!state.countriesHasMore) return; + + try { + final countriesResponse = await _countriesRepository.readAll( + cursor: state.countriesCursor, + filter: {'name': state.countrySearchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(countriesResponse.items), + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateSourceStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onLanguageSearchChanged( + CreateSourceLanguageSearchChanged event, + Emitter emit, + ) async { + emit(state.copyWith(languageSearchTerm: event.searchTerm)); + try { + final languagesResponse = await _languagesRepository.readAll( + filter: {'name': event.searchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + languages: languagesResponse.items, + languagesCursor: languagesResponse.cursor, + languagesHasMore: languagesResponse.hasMore, + ), + ); + } catch (e) { + // Error handling omitted for brevity, but should be implemented + } + } + + Future _onLoadMoreLanguagesRequested( + CreateSourceLoadMoreLanguagesRequested event, + Emitter emit, + ) async { + if (!state.languagesHasMore) return; + // Implementation similar to _onLoadMoreCountriesRequested + } } From 1a8818d99ee61a6ac4d1ff5e39b65cbf910bd7ad Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:25:09 +0100 Subject: [PATCH 13/22] feat(content_management): implement paginated logic in EditSourceBloc Refactors `EditSourceBloc` to support paginated and searchable data fetching for both countries and languages. - Updates `_onLoaded` to fetch only the first page of data for dropdowns. - Implements handlers for searching and loading more data for both countries and languages, with a debounce for search inputs. - Updates state with pagination cursors and `hasMore` flags from the repository responses. --- .../bloc/edit_source/edit_source_bloc.dart | 150 ++++++++++++++++-- 1 file changed, 139 insertions(+), 11 deletions(-) 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 fb652afd..8fd0f6dd 100644 --- a/lib/content_management/bloc/edit_source/edit_source_bloc.dart +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; @@ -7,6 +8,8 @@ import 'package:flutter/foundation.dart'; part 'edit_source_event.dart'; part 'edit_source_state.dart'; +const _searchDebounceDuration = Duration(milliseconds: 300); + /// A BLoC to manage the state of editing a single source. class EditSourceBloc extends Bloc { /// {@macro edit_source_bloc} @@ -29,6 +32,20 @@ class EditSourceBloc extends Bloc { on(_onHeadquartersChanged); on(_onStatusChanged); on(_onSubmitted); + on( + _onCountrySearchChanged, + transformer: debounce(_searchDebounceDuration), + ); + on( + _onLoadMoreCountriesRequested, + ); + on( + _onLanguageSearchChanged, + transformer: debounce(_searchDebounceDuration), + ); + on( + _onLoadMoreLanguagesRequested, + ); } final DataRepository _sourcesRepository; @@ -42,23 +59,20 @@ class EditSourceBloc extends Bloc { ) async { emit(state.copyWith(status: EditSourceStatus.loading)); try { - final [ - sourceResponse, - countriesResponse, - languagesResponse, - ] = await Future.wait([ + final [sourceResponse, countriesPaginated, languagesPaginated] = + await Future.wait([ _sourcesRepository.read(id: _sourceId), _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ), + ) as Future>, _languagesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ), + ) as Future>, ]); final source = sourceResponse as Source; - final countries = (countriesResponse as PaginatedResponse).items; - final languages = (languagesResponse as PaginatedResponse).items; + final countries = countriesPaginated.items; + final languages = languagesPaginated.items; // The source contains a Language object. We need to find the equivalent // object in the full list of languages to ensure the DropdownButton @@ -79,8 +93,12 @@ class EditSourceBloc extends Bloc { language: () => selectedLanguage, headquarters: () => source.headquarters, contentStatus: source.status, - countries: countries, - languages: languages, + countries: countriesPaginated.items, + countriesCursor: countriesPaginated.cursor, + countriesHasMore: countriesPaginated.hasMore, + languages: languagesPaginated.items, + languagesCursor: languagesPaginated.cursor, + languagesHasMore: languagesPaginated.hasMore, ), ); } on HttpException catch (e) { @@ -219,4 +237,114 @@ class EditSourceBloc extends Bloc { ); } } + + Future _onCountrySearchChanged( + EditSourceCountrySearchChanged event, + Emitter emit, + ) async { + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: {'name': event.searchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: countriesResponse.items, + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: EditSourceStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onLoadMoreCountriesRequested( + EditSourceLoadMoreCountriesRequested event, + Emitter emit, + ) async { + if (!state.countriesHasMore) return; + + try { + final countriesResponse = await _countriesRepository.readAll( + cursor: state.countriesCursor, + filter: {'name': state.countrySearchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + countries: List.of(state.countries)..addAll(countriesResponse.items), + countriesCursor: countriesResponse.cursor, + countriesHasMore: countriesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: EditSourceStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onLanguageSearchChanged( + EditSourceLanguageSearchChanged event, + Emitter emit, + ) async { + emit(state.copyWith(languageSearchTerm: event.searchTerm)); + try { + final languagesResponse = await _languagesRepository.readAll( + filter: {'name': event.searchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + languages: languagesResponse.items, + languagesCursor: languagesResponse.cursor, + languagesHasMore: languagesResponse.hasMore, + ), + ); + } catch (e) { + // Proper error handling should be implemented here + } + } + + Future _onLoadMoreLanguagesRequested( + EditSourceLoadMoreLanguagesRequested event, + Emitter emit, + ) async { + if (!state.languagesHasMore) return; + + try { + final languagesResponse = await _languagesRepository.readAll( + cursor: state.languagesCursor, + filter: {'name': state.languageSearchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + languages: List.of(state.languages)..addAll(languagesResponse.items), + languagesCursor: languagesResponse.cursor, + languagesHasMore: languagesResponse.hasMore, + ), + ); + } catch (e) { + // Proper error handling should be implemented here + } + } } From 97069e7eb80ab870e5e8a1b6a6143b20e4aa7b56 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:27:29 +0100 Subject: [PATCH 14/22] feat(content_management): integrate searchable dropdowns in source forms Replaces the standard `CountryDropdownFormField` and `LanguageDropdownFormField` with the new `SearchableDropdownFormField` in both the `CreateSourcePage` and `EditSourcePage`. This change connects the UI to the new pagination and search logic in the `CreateSourceBloc` and `EditSourceBloc`, allowing users to efficiently search and load countries and languages from large datasets. --- .../view/create_source_page.dart | 65 +++++++++++++++++-- .../view/edit_source_page.dart | 65 +++++++++++++++++-- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/lib/content_management/view/create_source_page.dart b/lib/content_management/view/create_source_page.dart index 2de3892a..a623b2b2 100644 --- a/lib/content_management/view/create_source_page.dart +++ b/lib/content_management/view/create_source_page.dart @@ -161,13 +161,29 @@ class _CreateSourceViewState extends State<_CreateSourceView> { .add(CreateSourceUrlChanged(value)), ), const SizedBox(height: AppSpacing.lg), - LanguageDropdownFormField( + SearchableDropdownFormField( labelText: l10n.language, - languages: state.languages, + items: state.languages, initialValue: state.language, + hasMore: state.languagesHasMore, + isLoading: state.status == CreateSourceStatus.loading, onChanged: (value) => context .read() .add(CreateSourceLanguageChanged(value)), + onSearchChanged: (value) => context + .read() + .add(CreateSourceLanguageSearchChanged(value)), + onLoadMore: () => context.read().add( + const CreateSourceLoadMoreLanguagesRequested(), + ), + itemBuilder: (context, language) { + return ListTile( + title: Text(language.name), + ); + }, + selectedItemBuilder: (context, language) { + return Text(language.name); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( @@ -190,13 +206,54 @@ class _CreateSourceViewState extends State<_CreateSourceView> { .add(CreateSourceTypeChanged(value)), ), const SizedBox(height: AppSpacing.lg), - CountryDropdownFormField( + SearchableDropdownFormField( labelText: l10n.headquarters, - countries: state.countries, + items: state.countries, initialValue: state.headquarters, + hasMore: state.countriesHasMore, + isLoading: state.status == CreateSourceStatus.loading, onChanged: (value) => context .read() .add(CreateSourceHeadquartersChanged(value)), + onSearchChanged: (value) => context + .read() + .add(CreateSourceCountrySearchChanged(value)), + onLoadMore: () => context.read().add( + const CreateSourceLoadMoreCountriesRequested(), + ), + itemBuilder: (context, country) { + return ListTile( + leading: SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + title: Text(country.name), + ); + }, + selectedItemBuilder: (context, country) { + return 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(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 b532b819..bbdb844e 100644 --- a/lib/content_management/view/edit_source_page.dart +++ b/lib/content_management/view/edit_source_page.dart @@ -191,13 +191,29 @@ class _EditSourceViewState extends State<_EditSourceView> { ), ), const SizedBox(height: AppSpacing.lg), - LanguageDropdownFormField( + SearchableDropdownFormField( labelText: l10n.language, - languages: state.languages, + items: state.languages, initialValue: state.language, + hasMore: state.languagesHasMore, + isLoading: state.status == EditSourceStatus.loading, onChanged: (value) => context .read() .add(EditSourceLanguageChanged(value)), + onSearchChanged: (value) => context + .read() + .add(EditSourceLanguageSearchChanged(value)), + onLoadMore: () => context.read().add( + const EditSourceLoadMoreLanguagesRequested(), + ), + itemBuilder: (context, language) { + return ListTile( + title: Text(language.name), + ); + }, + selectedItemBuilder: (context, language) { + return Text(language.name); + }, ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( @@ -220,13 +236,54 @@ class _EditSourceViewState extends State<_EditSourceView> { ), ), const SizedBox(height: AppSpacing.lg), - CountryDropdownFormField( + SearchableDropdownFormField( labelText: l10n.headquarters, - countries: state.countries, + items: state.countries, initialValue: state.headquarters, + hasMore: state.countriesHasMore, + isLoading: state.status == EditSourceStatus.loading, onChanged: (value) => context .read() .add(EditSourceHeadquartersChanged(value)), + onSearchChanged: (value) => context + .read() + .add(EditSourceCountrySearchChanged(value)), + onLoadMore: () => context.read().add( + const EditSourceLoadMoreCountriesRequested(), + ), + itemBuilder: (context, country) { + return ListTile( + leading: SizedBox( + width: 32, + height: 20, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.flag), + ), + ), + title: Text(country.name), + ); + }, + selectedItemBuilder: (context, country) { + return 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(height: AppSpacing.lg), DropdownButtonFormField( From 90db0c0d91bb14fdce0b0b7efab2a0afc15eec14 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:29:02 +0100 Subject: [PATCH 15/22] refactor(shared): remove obsolete dropdown widgets Deletes the `CountryDropdownFormField` and `LanguageDropdownFormField` widgets as they have been fully replaced by the new, more capable `SearchableDropdownFormField` across the application. Updates the shared widgets barrel file to remove the exports for the deleted files. --- .../widgets/country_dropdown_form_field.dart | 47 ------------------- .../widgets/language_dropdown_form_field.dart | 47 ------------------- lib/shared/widgets/widgets.dart | 2 - 3 files changed, 96 deletions(-) delete mode 100644 lib/shared/widgets/country_dropdown_form_field.dart delete mode 100644 lib/shared/widgets/language_dropdown_form_field.dart diff --git a/lib/shared/widgets/country_dropdown_form_field.dart b/lib/shared/widgets/country_dropdown_form_field.dart deleted file mode 100644 index edcb47d8..00000000 --- a/lib/shared/widgets/country_dropdown_form_field.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; - -/// A reusable dropdown form field for selecting a country. -class CountryDropdownFormField extends StatelessWidget { - /// {@macro country_dropdown_form_field} - const CountryDropdownFormField({ - required this.countries, - required this.onChanged, - this.initialValue, - this.labelText, - super.key, - }); - - /// The list of countries to display in the dropdown. - final List countries; - - /// The currently selected country. - final Country? initialValue; - - /// The callback that is called when the user selects a country. - final ValueChanged onChanged; - - /// The text to display as the label for the form field. - final String? labelText; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - - return DropdownButtonFormField( - value: initialValue, - decoration: InputDecoration( - labelText: labelText ?? l10n.countryName, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...countries.map( - (c) => DropdownMenuItem(value: c, child: Text(c.name)), - ), - ], - onChanged: onChanged, - ); - } -} diff --git a/lib/shared/widgets/language_dropdown_form_field.dart b/lib/shared/widgets/language_dropdown_form_field.dart deleted file mode 100644 index dc8e1d47..00000000 --- a/lib/shared/widgets/language_dropdown_form_field.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; - -/// A reusable dropdown form field for selecting a language. -class LanguageDropdownFormField extends StatelessWidget { - /// {@macro language_dropdown_form_field} - const LanguageDropdownFormField({ - required this.languages, - required this.onChanged, - this.initialValue, - this.labelText, - super.key, - }); - - /// The list of languages to display in the dropdown. - final List languages; - - /// The currently selected language. - final Language? initialValue; - - /// The callback that is called when the user selects a language. - final ValueChanged onChanged; - - /// The text to display as the label for the form field. - final String? labelText; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - - return DropdownButtonFormField( - value: initialValue, - decoration: InputDecoration( - labelText: labelText ?? l10n.language, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem(value: null, child: Text(l10n.none)), - ...languages.map( - (l) => DropdownMenuItem(value: l, child: Text(l.name)), - ), - ], - onChanged: onChanged, - ); - } -} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 18a77b20..70d878e5 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,3 +1 @@ -export 'country_dropdown_form_field.dart'; -export 'language_dropdown_form_field.dart'; export 'searchable_dropdown_form_field.dart'; From 6058e79c5427d67fd1dbcacf7e134928883e06ec Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:33:09 +0100 Subject: [PATCH 16/22] fix(content_management): correct debounce logic and complete handlers Replaces the incorrect `debounce` transformer with the correct `restartable` transformer from `bloc_concurrency` in all form BLoCs. This fixes the runtime error and correctly implements a debounce for search fields. Also completes the implementation for the language search and load-more handlers in `CreateSourceBloc` and `EditSourceBloc`, which were previously left incomplete. This includes adding proper error handling. --- .../create_headline/create_headline_bloc.dart | 3 +- .../create_source/create_source_bloc.dart | 42 +++++++++++++++++-- .../edit_headline/edit_headline_bloc.dart | 3 +- .../bloc/edit_source/edit_source_bloc.dart | 24 +++++++++-- lib/shared/widgets/widgets.dart | 2 + 5 files changed, 64 insertions(+), 10 deletions(-) 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 d026b5d5..6a056ff3 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -37,7 +37,7 @@ class CreateHeadlineBloc on(_onSubmitted); on( _onCountrySearchChanged, - transformer: debounce(_searchDebounceDuration), + transformer: restartable(), ); on( _onLoadMoreCountriesRequested); @@ -200,6 +200,7 @@ class CreateHeadlineBloc CreateHeadlineCountrySearchChanged event, Emitter emit, ) async { + await Future.delayed(_searchDebounceDuration); emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( 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 b4e7c370..51a4d94d 100644 --- a/lib/content_management/bloc/create_source/create_source_bloc.dart +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -33,14 +33,14 @@ class CreateSourceBloc extends Bloc { on(_onSubmitted); on( _onCountrySearchChanged, - transformer: debounce(_searchDebounceDuration), + transformer: restartable(), ); on( _onLoadMoreCountriesRequested, ); on( _onLanguageSearchChanged, - transformer: debounce(_searchDebounceDuration), + transformer: restartable(), ); on( _onLoadMoreLanguagesRequested, @@ -189,6 +189,7 @@ class CreateSourceBloc extends Bloc { CreateSourceCountrySearchChanged event, Emitter emit, ) async { + await Future.delayed(_searchDebounceDuration); emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( @@ -251,6 +252,7 @@ class CreateSourceBloc extends Bloc { CreateSourceLanguageSearchChanged event, Emitter emit, ) async { + await Future.delayed(_searchDebounceDuration); emit(state.copyWith(languageSearchTerm: event.searchTerm)); try { final languagesResponse = await _languagesRepository.readAll( @@ -265,8 +267,15 @@ class CreateSourceBloc extends Bloc { languagesHasMore: languagesResponse.hasMore, ), ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateSourceStatus.failure, exception: e)); } catch (e) { - // Error handling omitted for brevity, but should be implemented + emit( + state.copyWith( + status: CreateSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); } } @@ -275,6 +284,31 @@ class CreateSourceBloc extends Bloc { Emitter emit, ) async { if (!state.languagesHasMore) return; - // Implementation similar to _onLoadMoreCountriesRequested + + try { + final languagesResponse = await _languagesRepository.readAll( + cursor: state.languagesCursor, + filter: {'name': state.languageSearchTerm}, + sort: [const SortOption('name', SortOrder.asc)], + ) as PaginatedResponse; + + emit( + state.copyWith( + languages: List.of(state.languages) + ..addAll(languagesResponse.items), + languagesCursor: languagesResponse.cursor, + languagesHasMore: languagesResponse.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: CreateSourceStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } } } 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 f5c8fc11..e297a67d 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -37,7 +37,7 @@ class EditHeadlineBloc extends Bloc { on(_onSubmitted); on( _onCountrySearchChanged, - transformer: debounce(_searchDebounceDuration), + transformer: restartable(), ); on( _onLoadMoreCountriesRequested, @@ -250,6 +250,7 @@ class EditHeadlineBloc extends Bloc { EditHeadlineCountrySearchChanged event, Emitter emit, ) async { + await Future.delayed(_searchDebounceDuration); emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( 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 8fd0f6dd..fbad1dcf 100644 --- a/lib/content_management/bloc/edit_source/edit_source_bloc.dart +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -34,14 +34,14 @@ class EditSourceBloc extends Bloc { on(_onSubmitted); on( _onCountrySearchChanged, - transformer: debounce(_searchDebounceDuration), + transformer: restartable(), ); on( _onLoadMoreCountriesRequested, ); on( _onLanguageSearchChanged, - transformer: debounce(_searchDebounceDuration), + transformer: restartable(), ); on( _onLoadMoreLanguagesRequested, @@ -242,6 +242,7 @@ class EditSourceBloc extends Bloc { EditSourceCountrySearchChanged event, Emitter emit, ) async { + await Future.delayed(_searchDebounceDuration); emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( @@ -304,6 +305,7 @@ class EditSourceBloc extends Bloc { EditSourceLanguageSearchChanged event, Emitter emit, ) async { + await Future.delayed(_searchDebounceDuration); emit(state.copyWith(languageSearchTerm: event.searchTerm)); try { final languagesResponse = await _languagesRepository.readAll( @@ -318,8 +320,15 @@ class EditSourceBloc extends Bloc { languagesHasMore: languagesResponse.hasMore, ), ); + } on HttpException catch (e) { + emit(state.copyWith(status: EditSourceStatus.failure, exception: e)); } catch (e) { - // Proper error handling should be implemented here + emit( + state.copyWith( + status: EditSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); } } @@ -343,8 +352,15 @@ class EditSourceBloc extends Bloc { languagesHasMore: languagesResponse.hasMore, ), ); + } on HttpException catch (e) { + emit(state.copyWith(status: EditSourceStatus.failure, exception: e)); } catch (e) { - // Proper error handling should be implemented here + emit( + state.copyWith( + status: EditSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); } } } diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 70d878e5..18a77b20 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1 +1,3 @@ +export 'country_dropdown_form_field.dart'; +export 'language_dropdown_form_field.dart'; export 'searchable_dropdown_form_field.dart'; From e1381c422446791cd7e27033490ba80995dff3a7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:37:11 +0100 Subject: [PATCH 17/22] refactor(widgets): remove unused widget exports - Remove exports for 'country_dropdown_form_field.dart' and 'language_dropdown_form_field.dart' - Keep export for 'searchable_dropdown_form_field.dart' --- lib/shared/widgets/widgets.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 18a77b20..70d878e5 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,3 +1 @@ -export 'country_dropdown_form_field.dart'; -export 'language_dropdown_form_field.dart'; export 'searchable_dropdown_form_field.dart'; From 0936211dc24fa06dc0fb4babb4eb9df65019ef20 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:52:34 +0100 Subject: [PATCH 18/22] fix(content_management): improve country search and pagination - Update country search logic to handle empty search terms - Refine pagination handling when fetching more countries - Remove unnecessary type casting from repository responses --- .../create_headline/create_headline_bloc.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 6a056ff3..5b491ee0 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -69,7 +69,7 @@ class CreateHeadlineBloc final countriesResponse = await _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -204,9 +204,10 @@ class CreateHeadlineBloc emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( - filter: {'name': event.searchTerm}, + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -235,10 +236,14 @@ class CreateHeadlineBloc try { final countriesResponse = await _countriesRepository.readAll( - cursor: state.countriesCursor, - filter: {'name': state.countrySearchTerm}, + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( From 01df3d4f027b947db4d40ed94c2640db9805ee99 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:52:48 +0100 Subject: [PATCH 19/22] fix(content_management): improve pagination and filtering logic - Refactor initial data loading to use typed responses - Enhance country and language search to handle empty search terms - Add null safety checks for pagination options - Simplify response handling in various repository calls --- .../create_source/create_source_bloc.dart | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) 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 51a4d94d..fe4e63b2 100644 --- a/lib/content_management/bloc/create_source/create_source_bloc.dart +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -58,15 +58,16 @@ class CreateSourceBloc extends Bloc { ) async { emit(state.copyWith(status: CreateSourceStatus.loading)); try { - final [countriesPaginated, languagesPaginated] = await Future.wait([ + final responses = await Future.wait([ _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ) as Future>, + ), _languagesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ) as Future>, + ), ]); - + final countriesPaginated = responses[0] as PaginatedResponse; + final languagesPaginated = responses[1] as PaginatedResponse; emit( state.copyWith( status: CreateSourceStatus.initial, @@ -193,9 +194,10 @@ class CreateSourceBloc extends Bloc { emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( - filter: {'name': event.searchTerm}, + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -224,10 +226,14 @@ class CreateSourceBloc extends Bloc { try { final countriesResponse = await _countriesRepository.readAll( - cursor: state.countriesCursor, - filter: {'name': state.countrySearchTerm}, + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -256,9 +262,10 @@ class CreateSourceBloc extends Bloc { emit(state.copyWith(languageSearchTerm: event.searchTerm)); try { final languagesResponse = await _languagesRepository.readAll( - filter: {'name': event.searchTerm}, + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -287,10 +294,14 @@ class CreateSourceBloc extends Bloc { try { final languagesResponse = await _languagesRepository.readAll( - cursor: state.languagesCursor, - filter: {'name': state.languageSearchTerm}, + pagination: state.languagesCursor != null + ? PaginationOptions(cursor: state.languagesCursor) + : null, + filter: state.languageSearchTerm.isNotEmpty + ? {'name': state.languageSearchTerm} + : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( From 3a0a193a46e3a16202b0da05877e5f72ca5e1242 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:52:59 +0100 Subject: [PATCH 20/22] fix(content_management): improve countries filter and pagination - Enhance country filter to only apply when search term is not empty - Refine pagination logic for country list retrieval - Simplify response handling in Future.wait --- .../edit_headline/edit_headline_bloc.dart | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) 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 e297a67d..412cde1d 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -56,8 +56,7 @@ class EditHeadlineBloc extends Bloc { ) async { emit(state.copyWith(status: EditHeadlineStatus.loading)); try { - final [headlineResponse, sourcesResponse, topicsResponse] = - await Future.wait([ + final responses = await Future.wait([ _headlinesRepository.read(id: _headlineId), _sourcesRepository.readAll( sort: [const SortOption('updatedAt', SortOrder.desc)], @@ -67,13 +66,13 @@ class EditHeadlineBloc extends Bloc { ), ]); - final headline = headlineResponse as Headline; - final sources = (sourcesResponse as PaginatedResponse).items; - final topics = (topicsResponse as PaginatedResponse).items; + final headline = responses[0] as Headline; + final sources = (responses[1] as PaginatedResponse).items; + final topics = (responses[2] as PaginatedResponse).items; final countriesResponse = await _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -254,9 +253,10 @@ class EditHeadlineBloc extends Bloc { emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( - filter: {'name': event.searchTerm}, + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -285,10 +285,14 @@ class EditHeadlineBloc extends Bloc { try { final countriesResponse = await _countriesRepository.readAll( - cursor: state.countriesCursor, - filter: {'name': state.countrySearchTerm}, + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( From 5f5e4bb3ee148ad4250e7c542d22a33ac06bb017 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 20:53:09 +0100 Subject: [PATCH 21/22] refactor(content_management): improve type safety in edit source bloc - Use explicit type casting for repository responses - Simplify variable assignments and type inference - Remove unnecessary comments --- .../bloc/edit_source/edit_source_bloc.dart | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) 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 fbad1dcf..cd88ca50 100644 --- a/lib/content_management/bloc/edit_source/edit_source_bloc.dart +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -59,26 +59,21 @@ class EditSourceBloc extends Bloc { ) async { emit(state.copyWith(status: EditSourceStatus.loading)); try { - final [sourceResponse, countriesPaginated, languagesPaginated] = - await Future.wait([ + final responses = await Future.wait([ _sourcesRepository.read(id: _sourceId), _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ) as Future>, + ), _languagesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], - ) as Future>, + ), ]); - final source = sourceResponse as Source; - final countries = countriesPaginated.items; - final languages = languagesPaginated.items; + final source = responses[0] as Source; + final countriesPaginated = responses[1] as PaginatedResponse; + final languagesPaginated = responses[2] as PaginatedResponse; - // The source contains a Language object. We need to find the equivalent - // object in the full list of languages to ensure the DropdownButton - // can correctly identify and display the initial selection by reference. - final selectedLanguage = languages.firstWhere( - (listLanguage) => listLanguage == source.language, + final selectedLanguage = languagesPaginated.items orElse: () => source.language, ); From 3bb633b5ad5f4276ac428395e4dc706e1b144839 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 31 Jul 2025 21:03:57 +0100 Subject: [PATCH 22/22] fix(content_management): resolve errors in EditSourceBloc Corrects multiple errors in `EditSourceBloc`: - Fixes type inference for `Future.wait` by explicitly handling the returned list of dynamic objects. - Replaces incorrect syntax for finding the selected language with a robust `try-catch` block around `firstWhere`. - Correctly passes pagination parameters by wrapping the cursor in a `PaginationOptions` object. - Refines search logic to prevent sending filters with empty search terms. - Removes unnecessary casts. --- .../bloc/edit_source/edit_source_bloc.dart | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) 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 cd88ca50..c8d8c7a7 100644 --- a/lib/content_management/bloc/edit_source/edit_source_bloc.dart +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -59,7 +59,7 @@ class EditSourceBloc extends Bloc { ) async { emit(state.copyWith(status: EditSourceStatus.loading)); try { - final responses = await Future.wait([ + final responses = await Future.wait([ _sourcesRepository.read(id: _sourceId), _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], @@ -73,9 +73,16 @@ class EditSourceBloc extends Bloc { final countriesPaginated = responses[1] as PaginatedResponse; final languagesPaginated = responses[2] as PaginatedResponse; - final selectedLanguage = languagesPaginated.items - orElse: () => source.language, - ); + Language? selectedLanguage; + try { + // Find the equivalent language object from the full list. + // This ensures the DropdownButton can identify it by reference. + selectedLanguage = languagesPaginated.items.firstWhere( + (listLanguage) => listLanguage.id == source.language?.id, + ); + } catch (_) { + selectedLanguage = source.language; + } emit( state.copyWith( @@ -241,9 +248,10 @@ class EditSourceBloc extends Bloc { emit(state.copyWith(countrySearchTerm: event.searchTerm)); try { final countriesResponse = await _countriesRepository.readAll( - filter: {'name': event.searchTerm}, + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -272,10 +280,14 @@ class EditSourceBloc extends Bloc { try { final countriesResponse = await _countriesRepository.readAll( - cursor: state.countriesCursor, - filter: {'name': state.countrySearchTerm}, + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -304,9 +316,10 @@ class EditSourceBloc extends Bloc { emit(state.copyWith(languageSearchTerm: event.searchTerm)); try { final languagesResponse = await _languagesRepository.readAll( - filter: {'name': event.searchTerm}, + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith( @@ -335,10 +348,14 @@ class EditSourceBloc extends Bloc { try { final languagesResponse = await _languagesRepository.readAll( - cursor: state.languagesCursor, - filter: {'name': state.languageSearchTerm}, + pagination: state.languagesCursor != null + ? PaginationOptions(cursor: state.languagesCursor) + : null, + filter: state.languageSearchTerm.isNotEmpty + ? {'name': state.languageSearchTerm} + : null, sort: [const SortOption('name', SortOrder.asc)], - ) as PaginatedResponse; + ); emit( state.copyWith(