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 7a053c5..5b491ee 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: restartable(), + ); + on( + _onLoadMoreCountriesRequested); } final DataRepository _headlinesRepository; @@ -46,33 +55,30 @@ 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)], + ); 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) { @@ -189,4 +195,72 @@ class CreateHeadlineBloc ); } } + + Future _onCountrySearchChanged( + CreateHeadlineCountrySearchChanged event, + Emitter emit, + ) async { + await Future.delayed(_searchDebounceDuration); + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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( + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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'), + ), + ); + } + } } 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 655eae9..b2dd90d 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/create_headline/create_headline_state.dart b/lib/content_management/bloc/create_headline/create_headline_state.dart index 0739db1..c914953 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/create_source/create_source_bloc.dart b/lib/content_management/bloc/create_source/create_source_bloc.dart index 312f505..fe4e63b 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: restartable(), + ); + on( + _onLoadMoreCountriesRequested, + ); + on( + _onLanguageSearchChanged, + transformer: restartable(), + ); + on( + _onLoadMoreLanguagesRequested, + ); } final DataRepository _sourcesRepository; @@ -41,7 +58,7 @@ class CreateSourceBloc extends Bloc { ) async { emit(state.copyWith(status: CreateSourceStatus.loading)); try { - final [countriesResponse, languagesResponse] = await Future.wait([ + final responses = await Future.wait([ _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], ), @@ -49,19 +66,28 @@ class CreateSourceBloc extends Bloc { sort: [const SortOption('name', SortOrder.asc)], ), ]); - - final countries = (countriesResponse as PaginatedResponse).items; - final languages = (languagesResponse as PaginatedResponse).items; - + final countriesPaginated = responses[0] as PaginatedResponse; + final languagesPaginated = responses[1] as PaginatedResponse; 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) { + 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'), + ), + ); } } @@ -159,4 +185,141 @@ class CreateSourceBloc extends Bloc { ); } } + + Future _onCountrySearchChanged( + CreateSourceCountrySearchChanged event, + Emitter emit, + ) async { + await Future.delayed(_searchDebounceDuration); + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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( + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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 { + await Future.delayed(_searchDebounceDuration); + emit(state.copyWith(languageSearchTerm: event.searchTerm)); + try { + final languagesResponse = await _languagesRepository.readAll( + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + languages: 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'), + ), + ); + } + } + + Future _onLoadMoreLanguagesRequested( + CreateSourceLoadMoreLanguagesRequested event, + Emitter emit, + ) async { + if (!state.languagesHasMore) return; + + try { + final languagesResponse = await _languagesRepository.readAll( + pagination: state.languagesCursor != null + ? PaginationOptions(cursor: state.languagesCursor) + : null, + filter: state.languageSearchTerm.isNotEmpty + ? {'name': state.languageSearchTerm} + : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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/create_source/create_source_event.dart b/lib/content_management/bloc/create_source/create_source_event.dart index e4c4f56..d15f2f4 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/create_source/create_source_state.dart b/lib/content_management/bloc/create_source/create_source_state.dart index d86c2c5..783a200 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_headline/edit_headline_bloc.dart b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart index 02a64f4..412cde1 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'; @@ -7,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} @@ -32,6 +35,13 @@ class EditHeadlineBloc extends Bloc { on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); + on( + _onCountrySearchChanged, + transformer: restartable(), + ); + on( + _onLoadMoreCountriesRequested, + ); } final DataRepository _headlinesRepository; @@ -46,12 +56,7 @@ class EditHeadlineBloc extends Bloc { ) async { emit(state.copyWith(status: EditHeadlineStatus.loading)); try { - final [ - headlineResponse, - sourcesResponse, - topicsResponse, - countriesResponse, - ] = await Future.wait([ + final responses = await Future.wait([ _headlinesRepository.read(id: _headlineId), _sourcesRepository.readAll( sort: [const SortOption('updatedAt', SortOrder.desc)], @@ -59,15 +64,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 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)], + ); emit( state.copyWith( @@ -82,7 +87,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, ), ); @@ -237,4 +244,72 @@ class EditHeadlineBloc extends Bloc { ); } } + + Future _onCountrySearchChanged( + EditHeadlineCountrySearchChanged event, + Emitter emit, + ) async { + await Future.delayed(_searchDebounceDuration); + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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( + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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'), + ), + ); + } + } } 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 6bad7bd..f07256d 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(); +} 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 fba983f..11e5b07 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, 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 fb652af..c8d8c7a 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: restartable(), + ); + on( + _onLoadMoreCountriesRequested, + ); + on( + _onLanguageSearchChanged, + transformer: restartable(), + ); + on( + _onLoadMoreLanguagesRequested, + ); } final DataRepository _sourcesRepository; @@ -42,11 +59,7 @@ class EditSourceBloc extends Bloc { ) async { emit(state.copyWith(status: EditSourceStatus.loading)); try { - final [ - sourceResponse, - countriesResponse, - languagesResponse, - ] = await Future.wait([ + final responses = await Future.wait([ _sourcesRepository.read(id: _sourceId), _countriesRepository.readAll( sort: [const SortOption('name', SortOrder.asc)], @@ -56,17 +69,20 @@ class EditSourceBloc extends Bloc { ), ]); - final source = sourceResponse as Source; - final countries = (countriesResponse as PaginatedResponse).items; - final languages = (languagesResponse as PaginatedResponse).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, - 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( @@ -79,8 +95,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 +239,140 @@ class EditSourceBloc extends Bloc { ); } } + + Future _onCountrySearchChanged( + EditSourceCountrySearchChanged event, + Emitter emit, + ) async { + await Future.delayed(_searchDebounceDuration); + emit(state.copyWith(countrySearchTerm: event.searchTerm)); + try { + final countriesResponse = await _countriesRepository.readAll( + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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( + pagination: state.countriesCursor != null + ? PaginationOptions(cursor: state.countriesCursor) + : null, + filter: state.countrySearchTerm.isNotEmpty + ? {'name': state.countrySearchTerm} + : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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 { + await Future.delayed(_searchDebounceDuration); + emit(state.copyWith(languageSearchTerm: event.searchTerm)); + try { + final languagesResponse = await _languagesRepository.readAll( + filter: + event.searchTerm.isNotEmpty ? {'name': event.searchTerm} : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + languages: languagesResponse.items, + languagesCursor: languagesResponse.cursor, + languagesHasMore: languagesResponse.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 _onLoadMoreLanguagesRequested( + EditSourceLoadMoreLanguagesRequested event, + Emitter emit, + ) async { + if (!state.languagesHasMore) return; + + try { + final languagesResponse = await _languagesRepository.readAll( + pagination: state.languagesCursor != null + ? PaginationOptions(cursor: state.languagesCursor) + : null, + filter: state.languageSearchTerm.isNotEmpty + ? {'name': state.languageSearchTerm} + : null, + sort: [const SortOption('name', SortOrder.asc)], + ); + + 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: EditSourceStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } } 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 8620d9a..b68ec43 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(); +} 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 1c65c6a..f4e3281 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, diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index 204da4e..8311014 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/create_source_page.dart b/lib/content_management/view/create_source_page.dart index 2de3892..a623b2b 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_headline_page.dart b/lib/content_management/view/edit_headline_page.dart index 9387a25..2b9cc85 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( diff --git a/lib/content_management/view/edit_source_page.dart b/lib/content_management/view/edit_source_page.dart index b532b81..bbdb844 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( 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 edcb47d..0000000 --- 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 dc8e1d4..0000000 --- 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/searchable_dropdown_form_field.dart b/lib/shared/widgets/searchable_dropdown_form_field.dart new file mode 100644 index 0000000..d5887ee --- /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 c108713..70d878e 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,2 +1 @@ -export 'country_dropdown_form_field.dart'; -export 'language_dropdown_form_field.dart'; +export 'searchable_dropdown_form_field.dart';