From 36ba9731b044633e1843a8e6a8afdafb6504fb6e Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 1 Aug 2025 06:22:42 +0100 Subject: [PATCH 1/5] feat(ui): refactor SearchableDropdownFormField to be BLoC-aware Refactored `SearchableDropdownFormField` to make it a fully reactive component that rebuilds its internal search dialog based on the state of a provided BLoC. - Made the widget generic over BLoC and State types (`, S>`). - Replaced static `items`, `hasMore`, and `isLoading` parameters with constructor-injected "extractor" functions (`itemsExtractor`, `hasMoreExtractor`, `isLoadingExtractor`). These functions derive the necessary data from the BLoC's state. - The internal `_SearchableSelectionDialog` now uses a `BlocBuilder` to listen for state changes from the provided BLoC, ensuring the list of items and loading indicators are always up-to-date. --- .../searchable_dropdown_form_field.dart | 83 ++++++++++++------- 1 file changed, 52 insertions(+), 31 deletions(-) diff --git a/lib/shared/widgets/searchable_dropdown_form_field.dart b/lib/shared/widgets/searchable_dropdown_form_field.dart index d5887ee..e97b730 100644 --- a/lib/shared/widgets/searchable_dropdown_form_field.dart +++ b/lib/shared/widgets/searchable_dropdown_form_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ui_kit/ui_kit.dart'; /// A generic type for the builder function that creates list items in the @@ -21,17 +22,19 @@ typedef SearchableDropdownSelectedItemBuilder = Widget Function( /// 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 { +class SearchableDropdownFormField, S> + extends FormField { /// {@macro searchable_dropdown_form_field} SearchableDropdownFormField({ - required List items, + required B bloc, + required List Function(S state) itemsExtractor, + required bool Function(S state) hasMoreExtractor, + required bool Function(S state) isLoadingExtractor, 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, @@ -49,13 +52,14 @@ class SearchableDropdownFormField extends FormField { onTap: () async { final selectedItem = await showDialog( context: state.context, - builder: (context) => _SearchableSelectionDialog( - items: items, + builder: (context) => _SearchableSelectionDialog( + bloc: bloc, + itemsExtractor: itemsExtractor, + hasMoreExtractor: hasMoreExtractor, + isLoadingExtractor: isLoadingExtractor, onSearchChanged: onSearchChanged, onLoadMore: onLoadMore, itemBuilder: itemBuilder, - hasMore: hasMore, - isLoading: isLoading ?? false, searchHintText: searchHintText, noItemsFoundText: noItemsFoundText, ), @@ -83,35 +87,38 @@ class SearchableDropdownFormField extends FormField { } /// The modal dialog that contains the searchable and paginated list. -class _SearchableSelectionDialog extends StatefulWidget { +class _SearchableSelectionDialog, S> + extends StatefulWidget { const _SearchableSelectionDialog({ - required this.items, + required this.bloc, + required this.itemsExtractor, + required this.hasMoreExtractor, + required this.isLoadingExtractor, 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 B bloc; + final List Function(S state) itemsExtractor; + final bool Function(S state) hasMoreExtractor; + final bool Function(S state) isLoadingExtractor; 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(); + State<_SearchableSelectionDialog> createState() => + _SearchableSelectionDialogState(); } -class _SearchableSelectionDialogState - extends State<_SearchableSelectionDialog> { +class _SearchableSelectionDialogState, S> + extends State<_SearchableSelectionDialog> { final _scrollController = ScrollController(); final _searchController = TextEditingController(); @@ -168,7 +175,16 @@ class _SearchableSelectionDialogState ), const SizedBox(height: AppSpacing.md), Expanded( - child: _buildList(), + child: BlocBuilder( + bloc: widget.bloc, + builder: (context, state) { + final items = widget.itemsExtractor(state); + final hasMore = widget.hasMoreExtractor(state); + final isLoading = widget.isLoadingExtractor(state); + + return _buildList(items, hasMore, isLoading); + }, + ), ), ], ), @@ -177,12 +193,12 @@ class _SearchableSelectionDialogState ); } - Widget _buildList() { - if (widget.isLoading && widget.items.isEmpty) { + Widget _buildList(List items, bool hasMore, bool isLoading) { + if (isLoading && items.isEmpty) { return const Center(child: CircularProgressIndicator()); } - if (widget.items.isEmpty) { + if (items.isEmpty) { return Center( child: Text(widget.noItemsFoundText ?? 'No items found.'), ); @@ -190,16 +206,21 @@ class _SearchableSelectionDialogState return ListView.builder( controller: _scrollController, - itemCount: - widget.hasMore ? widget.items.length + 1 : widget.items.length, + itemCount: items.length + (hasMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= widget.items.length) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.md), - child: Center(child: CircularProgressIndicator()), - ); + if (index >= items.length) { + // This is the last item, which is the loading indicator. + // It's only shown if we have more items and are currently loading. + return isLoading + ? const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); } - final item = widget.items[index]; + + // This is a regular item. + final item = items[index]; return InkWell( onTap: () => Navigator.of(context).pop(item), child: widget.itemBuilder(context, item), From 0b3a1c99e78576e6f7dd9232e86b3defe5ebcf59 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 1 Aug 2025 06:23:48 +0100 Subject: [PATCH 2/5] feat(content): update create headline page to use reactive dropdown Refactored the `create_headline_page.dart` to use the new BLoC-aware `SearchableDropdownFormField` for country selection. - Replaced the old dropdown implementation with the new generic `SearchableDropdownFormField`. - Injected the `CreateHeadlineBloc` instance into the widget. - Provided extractor functions (`itemsExtractor`, `hasMoreExtractor`, `isLoadingExtractor`) to enable the dropdown to reactively pull data from the BLoC's state. This change ensures that the country list in the search dialog updates automatically when more items are loaded via pagination, fixing the core UI refresh bug. --- lib/content_management/view/create_headline_page.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index 8311014..70dfc29 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -214,12 +214,15 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { .add(CreateHeadlineTopicChanged(value)), ), const SizedBox(height: AppSpacing.lg), - SearchableDropdownFormField( + SearchableDropdownFormField( labelText: l10n.countryName, - items: state.countries, + bloc: context.read(), initialValue: state.eventCountry, - hasMore: state.countriesHasMore, - isLoading: state.status == CreateHeadlineStatus.loading, + itemsExtractor: (state) => state.countries, + hasMoreExtractor: (state) => state.countriesHasMore, + isLoadingExtractor: (state) => + state.status == CreateHeadlineStatus.loading, onChanged: (value) => context .read() .add(CreateHeadlineCountryChanged(value)), From 7cefb3caa3daa744ab5427086ee84a700429b6dd Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 1 Aug 2025 06:24:52 +0100 Subject: [PATCH 3/5] feat(content): update edit headline page to use reactive dropdown Refactored the `edit_headline_page.dart` to use the new BLoC-aware `SearchableDropdownFormField` for country selection. - Replaced the old dropdown implementation with the new generic `SearchableDropdownFormField`. - Injected the `EditHeadlineBloc` instance into the widget. - Provided extractor functions (`itemsExtractor`, `hasMoreExtractor`, `isLoadingExtractor`) to enable the dropdown to reactively pull data from the BLoC's state. This change ensures that the country list in the search dialog updates automatically when more items are loaded via pagination, fixing the core UI refresh bug. --- lib/content_management/view/edit_headline_page.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/content_management/view/edit_headline_page.dart b/lib/content_management/view/edit_headline_page.dart index 2b9cc85..b0b9df2 100644 --- a/lib/content_management/view/edit_headline_page.dart +++ b/lib/content_management/view/edit_headline_page.dart @@ -282,12 +282,15 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { .add(EditHeadlineTopicChanged(value)), ), const SizedBox(height: AppSpacing.lg), - SearchableDropdownFormField( + SearchableDropdownFormField( labelText: l10n.countryName, - items: state.countries, + bloc: context.read(), initialValue: selectedCountry, - hasMore: state.countriesHasMore, - isLoading: state.status == EditHeadlineStatus.loading, + itemsExtractor: (state) => state.countries, + hasMoreExtractor: (state) => state.countriesHasMore, + isLoadingExtractor: (state) => + state.status == EditHeadlineStatus.loading, onChanged: (value) => context .read() .add(EditHeadlineCountryChanged(value)), From d429c4abbfddea0b2dc8858fa2bcd4c456977d7c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 1 Aug 2025 06:25:44 +0100 Subject: [PATCH 4/5] feat(content): update create source page to use reactive dropdowns Refactored the `create_source_page.dart` to use the new BLoC-aware `SearchableDropdownFormField` for both language and country selection. - Replaced the old dropdown implementations with the new generic `SearchableDropdownFormField`. - Injected the `CreateSourceBloc` instance into each widget. - Provided extractor functions (`itemsExtractor`, `hasMoreExtractor`, `isLoadingExtractor`) to enable the dropdowns to reactively pull the correct data (languages or countries) from the BLoC's state. This change ensures that both the language and country lists in their respective search dialogs update automatically when more items are loaded via pagination. --- .../view/create_source_page.dart | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/content_management/view/create_source_page.dart b/lib/content_management/view/create_source_page.dart index a623b2b..82c921c 100644 --- a/lib/content_management/view/create_source_page.dart +++ b/lib/content_management/view/create_source_page.dart @@ -161,12 +161,15 @@ class _CreateSourceViewState extends State<_CreateSourceView> { .add(CreateSourceUrlChanged(value)), ), const SizedBox(height: AppSpacing.lg), - SearchableDropdownFormField( + SearchableDropdownFormField( labelText: l10n.language, - items: state.languages, + bloc: context.read(), initialValue: state.language, - hasMore: state.languagesHasMore, - isLoading: state.status == CreateSourceStatus.loading, + itemsExtractor: (state) => state.languages, + hasMoreExtractor: (state) => state.languagesHasMore, + isLoadingExtractor: (state) => + state.status == CreateSourceStatus.loading, onChanged: (value) => context .read() .add(CreateSourceLanguageChanged(value)), @@ -206,12 +209,15 @@ class _CreateSourceViewState extends State<_CreateSourceView> { .add(CreateSourceTypeChanged(value)), ), const SizedBox(height: AppSpacing.lg), - SearchableDropdownFormField( + SearchableDropdownFormField( labelText: l10n.headquarters, - items: state.countries, + bloc: context.read(), initialValue: state.headquarters, - hasMore: state.countriesHasMore, - isLoading: state.status == CreateSourceStatus.loading, + itemsExtractor: (state) => state.countries, + hasMoreExtractor: (state) => state.countriesHasMore, + isLoadingExtractor: (state) => + state.status == CreateSourceStatus.loading, onChanged: (value) => context .read() .add(CreateSourceHeadquartersChanged(value)), From 332cce0bff322c3550c3ba51c01f8a12651743d0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 1 Aug 2025 06:26:35 +0100 Subject: [PATCH 5/5] refactor: update edit source page to use reactive dropdowns Refactored the `edit_source_page.dart` to use the new BLoC-aware `SearchableDropdownFormField` for both language and country selection. - Replaced the old dropdown implementations with the new generic `SearchableDropdownFormField`. - Injected the `EditSourceBloc` instance into each widget. - Provided extractor functions (`itemsExtractor`, `hasMoreExtractor`, `isLoadingExtractor`) to enable the dropdowns to reactively pull the correct data (languages or countries) from the BLoC's state. This change ensures that both the language and country lists in their respective search dialogs update automatically when more items are loaded via pagination, completing the bug fix. --- .../view/edit_source_page.dart | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/content_management/view/edit_source_page.dart b/lib/content_management/view/edit_source_page.dart index bbdb844..22529b3 100644 --- a/lib/content_management/view/edit_source_page.dart +++ b/lib/content_management/view/edit_source_page.dart @@ -191,12 +191,15 @@ class _EditSourceViewState extends State<_EditSourceView> { ), ), const SizedBox(height: AppSpacing.lg), - SearchableDropdownFormField( + SearchableDropdownFormField( labelText: l10n.language, - items: state.languages, + bloc: context.read(), initialValue: state.language, - hasMore: state.languagesHasMore, - isLoading: state.status == EditSourceStatus.loading, + itemsExtractor: (state) => state.languages, + hasMoreExtractor: (state) => state.languagesHasMore, + isLoadingExtractor: (state) => + state.status == EditSourceStatus.loading, onChanged: (value) => context .read() .add(EditSourceLanguageChanged(value)), @@ -236,12 +239,15 @@ class _EditSourceViewState extends State<_EditSourceView> { ), ), const SizedBox(height: AppSpacing.lg), - SearchableDropdownFormField( + SearchableDropdownFormField( labelText: l10n.headquarters, - items: state.countries, + bloc: context.read(), initialValue: state.headquarters, - hasMore: state.countriesHasMore, - isLoading: state.status == EditSourceStatus.loading, + itemsExtractor: (state) => state.countries, + hasMoreExtractor: (state) => state.countriesHasMore, + isLoadingExtractor: (state) => + state.status == EditSourceStatus.loading, onChanged: (value) => context .read() .add(EditSourceHeadquartersChanged(value)),