diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index 687a83c5..fbb73ac2 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -6,12 +6,7 @@ import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository import 'package:ht_main/headlines-feed/models/headline_filter.dart'; import 'package:ht_shared/ht_shared.dart' - show - Category, - // Country, // Removed as it's no longer used for headline filtering - Headline, - HtHttpException, - Source; // Shared models and standardized exceptions + show Headline, HtHttpException; // Shared models and standardized exceptions part 'headlines_feed_event.dart'; part 'headlines_feed_state.dart'; diff --git a/lib/headlines-feed/bloc/sources_filter_bloc.dart b/lib/headlines-feed/bloc/sources_filter_bloc.dart index 048a98bf..272aea1d 100644 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ b/lib/headlines-feed/bloc/sources_filter_bloc.dart @@ -3,8 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart' - show Country, HtHttpException, Source, SourceType; +import 'package:ht_shared/ht_shared.dart' show Country, Source, SourceType; part 'sources_filter_event.dart'; part 'sources_filter_state.dart'; diff --git a/lib/headlines-feed/bloc/sources_filter_event.dart b/lib/headlines-feed/bloc/sources_filter_event.dart index e5ff1220..1ba408f9 100644 --- a/lib/headlines-feed/bloc/sources_filter_event.dart +++ b/lib/headlines-feed/bloc/sources_filter_event.dart @@ -64,8 +64,3 @@ class SourceCheckboxToggled extends SourcesFilterEvent { class ClearSourceFiltersRequested extends SourcesFilterEvent { const ClearSourceFiltersRequested(); } - -// Internal event - not part of public API, hence leading underscore -class _FetchFilteredSourcesRequested extends SourcesFilterEvent { - const _FetchFilteredSourcesRequested(); -} diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart index f48cb161..78a48d08 100644 --- a/lib/headlines-feed/widgets/headline_item_widget.dart +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/constants/constants.dart'; // Import AppSpacing import 'package:ht_shared/ht_shared.dart'; // Import models from ht_shared import 'package:intl/intl.dart'; // For date formatting diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 3cb8bef1..14d6c39f 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository +import 'package:ht_main/headlines-search/models/search_model_type.dart'; // Import SearchModelType import 'package:ht_shared/ht_shared.dart'; // Shared models, including Headline part 'headlines_search_event.dart'; @@ -9,10 +10,17 @@ part 'headlines_search_state.dart'; class HeadlinesSearchBloc extends Bloc { - HeadlinesSearchBloc({required HtDataRepository headlinesRepository}) - : _headlinesRepository = headlinesRepository, - super(const HeadlinesSearchInitial()) { - // Start with Initial state + HeadlinesSearchBloc({ + required HtDataRepository headlinesRepository, + required HtDataRepository categoryRepository, + required HtDataRepository sourceRepository, + required HtDataRepository countryRepository, + }) : _headlinesRepository = headlinesRepository, + _categoryRepository = categoryRepository, + _sourceRepository = sourceRepository, + _countryRepository = countryRepository, + super(const HeadlinesSearchInitial()) { + on(_onHeadlinesSearchModelTypeChanged); on( _onSearchFetchRequested, transformer: restartable(), // Process only the latest search @@ -20,90 +28,162 @@ class HeadlinesSearchBloc } final HtDataRepository _headlinesRepository; + final HtDataRepository _categoryRepository; + final HtDataRepository _sourceRepository; + final HtDataRepository _countryRepository; static const _limit = 10; + Future _onHeadlinesSearchModelTypeChanged( + HeadlinesSearchModelTypeChanged event, + Emitter emit, + ) async { + // If there's an active search term, re-trigger search with new model type + // ignore: unused_local_variable + final currentSearchTerm = + state is HeadlinesSearchLoading + ? (state as HeadlinesSearchLoading).lastSearchTerm + : state is HeadlinesSearchSuccess + ? (state as HeadlinesSearchSuccess).lastSearchTerm + : state is HeadlinesSearchFailure + ? (state as HeadlinesSearchFailure).lastSearchTerm + : null; + + emit(HeadlinesSearchInitial(selectedModelType: event.newModelType)); + + // Removed automatic re-search: + // if (currentSearchTerm != null && currentSearchTerm.isNotEmpty) { + // add(HeadlinesSearchFetchRequested(searchTerm: currentSearchTerm)); + // } + } + Future _onSearchFetchRequested( HeadlinesSearchFetchRequested event, Emitter emit, ) async { - if (event.searchTerm.isEmpty) { + final searchTerm = event.searchTerm; + final modelType = state.selectedModelType; + + if (searchTerm.isEmpty) { emit( - const HeadlinesSearchSuccess( - headlines: [], + HeadlinesSearchSuccess( + results: const [], hasMore: false, lastSearchTerm: '', + selectedModelType: modelType, ), ); return; } - // Check if current state is success and if the search term is the same for pagination + // Handle pagination if (state is HeadlinesSearchSuccess) { final successState = state as HeadlinesSearchSuccess; - if (event.searchTerm == successState.lastSearchTerm) { - // This is a pagination request for the current search term - if (!successState.hasMore) return; // No more items to paginate + if (searchTerm == successState.lastSearchTerm && + modelType == successState.selectedModelType) { + if (!successState.hasMore) return; - // It's a bit unusual to emit Loading here for pagination, - // typically UI handles this. Let's keep it simple for now. - // emit(HeadlinesSearchLoading(lastSearchTerm: event.searchTerm)); try { - final response = await _headlinesRepository.readAllByQuery( - {'q': event.searchTerm}, - limit: _limit, - startAfterId: successState.cursor, - ); + PaginatedResponse response; + switch (modelType) { + case SearchModelType.headline: + response = await _headlinesRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + startAfterId: successState.cursor, + ); + case SearchModelType.category: + response = await _categoryRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + startAfterId: successState.cursor, + ); + case SearchModelType.source: + response = await _sourceRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + startAfterId: successState.cursor, + ); + case SearchModelType.country: + response = await _countryRepository.readAllByQuery( + {'q': searchTerm, 'model': modelType.toJson()}, + limit: _limit, + startAfterId: successState.cursor, + ); + } emit( - response.items.isEmpty - ? successState.copyWith(hasMore: false) - : successState.copyWith( - headlines: List.of(successState.headlines) - ..addAll(response.items), - hasMore: response.hasMore, - cursor: response.cursor, - ), + successState.copyWith( + results: List.of(successState.results)..addAll(response.items), + hasMore: response.hasMore, + cursor: response.cursor, + ), ); } on HtHttpException catch (e) { emit(successState.copyWith(errorMessage: e.message)); } catch (e, st) { - print('Search pagination error: $e\n$st'); + print('Search pagination error ($modelType): $e\n$st'); emit( successState.copyWith(errorMessage: 'Failed to load more results.'), ); } - return; // Pagination handled + return; } } - // If not paginating for the same term, it's a new search or different term + // New search emit( - HeadlinesSearchLoading(lastSearchTerm: event.searchTerm), - ); // Show loading for new search + HeadlinesSearchLoading( + lastSearchTerm: searchTerm, + selectedModelType: modelType, + ), + ); try { - final response = await _headlinesRepository.readAllByQuery({ - 'q': event.searchTerm, - }, limit: _limit); + PaginatedResponse response; + switch (modelType) { + case SearchModelType.headline: + response = await _headlinesRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit,); + case SearchModelType.category: + response = await _categoryRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit,); + case SearchModelType.source: + response = await _sourceRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit,); + case SearchModelType.country: + response = await _countryRepository.readAllByQuery({ + 'q': searchTerm, + 'model': modelType.toJson(), + }, limit: _limit,); + } emit( HeadlinesSearchSuccess( - headlines: response.items, + results: response.items, hasMore: response.hasMore, cursor: response.cursor, - lastSearchTerm: event.searchTerm, + lastSearchTerm: searchTerm, + selectedModelType: modelType, ), ); } on HtHttpException catch (e) { emit( HeadlinesSearchFailure( errorMessage: e.message, - lastSearchTerm: event.searchTerm, + lastSearchTerm: searchTerm, + selectedModelType: modelType, ), ); } catch (e, st) { - print('Search error: $e\n$st'); + print('Search error ($modelType): $e\n$st'); emit( HeadlinesSearchFailure( errorMessage: 'An unexpected error occurred during search.', - lastSearchTerm: event.searchTerm, + lastSearchTerm: searchTerm, + selectedModelType: modelType, ), ); } diff --git a/lib/headlines-search/bloc/headlines_search_event.dart b/lib/headlines-search/bloc/headlines_search_event.dart index 8a04558b..ab1a105d 100644 --- a/lib/headlines-search/bloc/headlines_search_event.dart +++ b/lib/headlines-search/bloc/headlines_search_event.dart @@ -7,6 +7,15 @@ sealed class HeadlinesSearchEvent extends Equatable { List get props => []; } +final class HeadlinesSearchModelTypeChanged extends HeadlinesSearchEvent { + const HeadlinesSearchModelTypeChanged(this.newModelType); + + final SearchModelType newModelType; + + @override + List get props => [newModelType]; +} + final class HeadlinesSearchFetchRequested extends HeadlinesSearchEvent { const HeadlinesSearchFetchRequested({required this.searchTerm}); diff --git a/lib/headlines-search/bloc/headlines_search_state.dart b/lib/headlines-search/bloc/headlines_search_state.dart index 2a0007d1..09d35295 100644 --- a/lib/headlines-search/bloc/headlines_search_state.dart +++ b/lib/headlines-search/bloc/headlines_search_state.dart @@ -1,64 +1,74 @@ part of 'headlines_search_bloc.dart'; abstract class HeadlinesSearchState extends Equatable { - const HeadlinesSearchState(); - // lastSearchTerm will be defined in specific states that need it. + const HeadlinesSearchState({ + this.selectedModelType = SearchModelType.headline, + }); + + final SearchModelType selectedModelType; + @override - List get props => []; + List get props => [selectedModelType]; } /// Initial state before any search is performed. class HeadlinesSearchInitial extends HeadlinesSearchState { - const HeadlinesSearchInitial(); - // No lastSearchTerm needed for initial state. + const HeadlinesSearchInitial({super.selectedModelType}); } /// State when a search is actively in progress. class HeadlinesSearchLoading extends HeadlinesSearchState { - const HeadlinesSearchLoading({this.lastSearchTerm}); - final String? lastSearchTerm; // Term being loaded + const HeadlinesSearchLoading({ + required this.lastSearchTerm, + required super.selectedModelType, + }); + final String lastSearchTerm; // Term being loaded @override - List get props => [lastSearchTerm]; + List get props => [...super.props, lastSearchTerm]; } /// State when a search has successfully returned results. class HeadlinesSearchSuccess extends HeadlinesSearchState { const HeadlinesSearchSuccess({ - required this.headlines, + required this.results, required this.hasMore, required this.lastSearchTerm, + required super.selectedModelType, // The model type for these results this.cursor, this.errorMessage, // For non-critical errors like pagination failure }); - final List headlines; + final List results; // Can hold Headline, Category, Source, Country final bool hasMore; final String? cursor; final String? errorMessage; // e.g., for pagination errors final String lastSearchTerm; // The term that yielded these results HeadlinesSearchSuccess copyWith({ - List? headlines, + List? results, bool? hasMore, String? cursor, - String? errorMessage, // Allow clearing/setting error + String? errorMessage, String? lastSearchTerm, + SearchModelType? selectedModelType, bool clearErrorMessage = false, }) { return HeadlinesSearchSuccess( - headlines: headlines ?? this.headlines, + results: results ?? this.results, hasMore: hasMore ?? this.hasMore, cursor: cursor ?? this.cursor, errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, lastSearchTerm: lastSearchTerm ?? this.lastSearchTerm, + selectedModelType: selectedModelType ?? this.selectedModelType, ); } @override List get props => [ - headlines, + ...super.props, + results, hasMore, cursor, errorMessage, @@ -71,11 +81,12 @@ class HeadlinesSearchFailure extends HeadlinesSearchState { const HeadlinesSearchFailure({ required this.errorMessage, required this.lastSearchTerm, + required super.selectedModelType, }); final String errorMessage; final String lastSearchTerm; // The term that failed @override - List get props => [errorMessage, lastSearchTerm]; + List get props => [...super.props, errorMessage, lastSearchTerm]; } diff --git a/lib/headlines-search/models/search_model_type.dart b/lib/headlines-search/models/search_model_type.dart new file mode 100644 index 00000000..de508d5d --- /dev/null +++ b/lib/headlines-search/models/search_model_type.dart @@ -0,0 +1,28 @@ +/// Defines the types of models that can be searched. +enum SearchModelType { + headline, + category, + country, + source; + + /// Returns a user-friendly display name for the enum value. + /// + /// This should ideally be localized using context.l10n, + /// but for simplicity in this step, we'll use direct strings. + /// TODO(Cline): Localize these display names. + String get displayName { + switch (this) { + case SearchModelType.headline: + return 'Headlines'; + case SearchModelType.category: + return 'Categories'; + case SearchModelType.country: + return 'Countries'; + case SearchModelType.source: + return 'Sources'; + } + } + + /// Returns the string representation for API query parameters. + String toJson() => name; +} diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 335e116d..9139bc13 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -4,12 +4,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; -import 'package:ht_main/router/routes.dart'; // Import Routes import 'package:ht_main/headlines-search/bloc/headlines_search_bloc.dart'; +import 'package:ht_main/headlines-search/models/search_model_type.dart'; // Import SearchModelType +// Import new item widgets +import 'package:ht_main/headlines-search/widgets/category_item_widget.dart'; +import 'package:ht_main/headlines-search/widgets/country_item_widget.dart'; +import 'package:ht_main/headlines-search/widgets/source_item_widget.dart'; import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; // Import Routes import 'package:ht_main/shared/constants/app_spacing.dart'; // Import AppSpacing import 'package:ht_main/shared/widgets/failure_state_widget.dart'; import 'package:ht_main/shared/widgets/initial_state_widget.dart'; +import 'package:ht_shared/ht_shared.dart'; // Import shared models /// Page widget responsible for providing the BLoC for the headlines search feature. class HeadlinesSearchPage extends StatelessWidget { @@ -37,54 +43,51 @@ class _HeadlinesSearchView extends StatefulWidget { class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final _scrollController = ScrollController(); - final _textController = - TextEditingController(); // Controller for the TextField - bool _showClearButton = false; // State to control clear button visibility + final _textController = TextEditingController(); + bool _showClearButton = false; + SearchModelType _selectedModelType = + SearchModelType.headline; // Initial selection @override void initState() { super.initState(); _scrollController.addListener(_onScroll); - // Listen to text changes to control clear button visibility _textController.addListener(() { setState(() { _showClearButton = _textController.text.isNotEmpty; }); }); + // Set initial model type in BLoC if not already set (e.g. on first load) + // Though BLoC state now defaults, this ensures UI and BLoC are in sync. + context.read().add( + HeadlinesSearchModelTypeChanged(_selectedModelType), + ); } @override void dispose() { - _scrollController - ..removeListener(_onScroll) - ..dispose(); - _textController.dispose(); // Dispose the text controller + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + _textController.dispose(); super.dispose(); } - /// Handles scroll events to trigger fetching more results when near the bottom. void _onScroll() { final state = context.read().state; - if (_isBottom && state is HeadlinesSearchSuccess) { - final searchTerm = state.lastSearchTerm; - if (state.hasMore) { - context.read().add( - HeadlinesSearchFetchRequested(searchTerm: searchTerm), - ); - } + if (_isBottom && state is HeadlinesSearchSuccess && state.hasMore) { + context.read().add( + HeadlinesSearchFetchRequested(searchTerm: state.lastSearchTerm), + ); } } - /// Checks if the scroll position is near the bottom of the list. bool get _isBottom { if (!_scrollController.hasClients) return false; final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.offset; - // Trigger slightly before the absolute bottom for a smoother experience return currentScroll >= (maxScroll * 0.98); } - /// Triggers a search request based on the current text input. void _performSearch() { context.read().add( HeadlinesSearchFetchRequested(searchTerm: _textController.text), @@ -100,134 +103,224 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { return Scaffold( appBar: AppBar( - // Enhanced TextField integrated into the AppBar title - title: TextField( - controller: _textController, - - style: appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge, - decoration: InputDecoration( - hintText: l10n.headlinesSearchHintText, - - hintStyle: - appBarTheme.titleTextStyle?.copyWith( - color: (appBarTheme.titleTextStyle?.color ?? - colorScheme.onSurface) - .withAlpha(153), // Replaced withOpacity(0.6) - ) ?? - theme.textTheme.titleLarge?.copyWith( - color: colorScheme.onSurface.withAlpha( - 153, - ), // Replaced withOpacity(0.6) + // Removed leading and leadingWidth + titleSpacing: AppSpacing.paddingSmall, // Adjust title spacing if needed + title: Row( + children: [ + SizedBox( + width: 140, // Constrain dropdown width + child: DropdownButtonFormField( + value: _selectedModelType, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.xs, // Minimal horizontal padding + vertical: AppSpacing.xs, // Reduce vertical padding + ), ), - // Remove the default border - border: InputBorder.none, - // Remove focused border highlight if any - focusedBorder: InputBorder.none, - // Remove enabled border highlight if any - enabledBorder: InputBorder.none, - // Add a subtle background fill - filled: true, - - fillColor: colorScheme.surface.withAlpha( - 26, - ), // Replaced withOpacity(0.1) - // Apply consistent padding using AppSpacing - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: - AppSpacing.paddingSmall, // Adjust vertical padding as needed + style: theme.textTheme.titleMedium?.copyWith( + color: + appBarTheme.titleTextStyle?.color ?? + colorScheme.onSurface, + ), + dropdownColor: colorScheme.surfaceContainerHighest, + icon: Icon( + Icons.arrow_drop_down, + color: appBarTheme.iconTheme?.color ?? colorScheme.onSurface, + ), + items: + SearchModelType.values.map((SearchModelType type) { + String displayLocalizedName; + switch (type) { + case SearchModelType.headline: + displayLocalizedName = l10n.searchModelTypeHeadline; + case SearchModelType.category: + displayLocalizedName = l10n.searchModelTypeCategory; + case SearchModelType.source: + displayLocalizedName = l10n.searchModelTypeSource; + case SearchModelType.country: + displayLocalizedName = l10n.searchModelTypeCountry; + } + return DropdownMenuItem( + value: type, + child: Text( + displayLocalizedName, + style: theme.textTheme.titleMedium?.copyWith( + // Consistent style + color: colorScheme.onSurface, + ), + ), + ); + }).toList(), + onChanged: (SearchModelType? newValue) { + if (newValue != null) { + setState(() { + _selectedModelType = newValue; + }); + context.read().add( + HeadlinesSearchModelTypeChanged(newValue), + ); + } + }, + ), ), - // Add a clear button that appears when text is entered - suffixIcon: - _showClearButton - ? IconButton( - icon: Icon( - Icons.clear, - - color: - appBarTheme.iconTheme?.color ?? - colorScheme.onSurface, - ), - onPressed: _textController.clear, - ) - : null, // No icon when text field is empty - ), - // Trigger search on submit (e.g., pressing Enter on keyboard) - onSubmitted: (_) => _performSearch(), + const SizedBox( + width: AppSpacing.sm, + ), // Spacing between dropdown and textfield + Expanded( + child: TextField( + controller: _textController, + style: appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge, + decoration: InputDecoration( + hintText: _getHintTextForModelType(_selectedModelType, l10n), + hintStyle: theme.textTheme.bodyMedium?.copyWith( + color: (appBarTheme.titleTextStyle?.color ?? + colorScheme.onSurface) + .withAlpha(153), + ), + border: InputBorder.none, + filled: true, + fillColor: colorScheme.surface.withAlpha(26), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: + AppSpacing.paddingSmall + + 3, // Fine-tune vertical padding for alignment + ), + suffixIcon: + _showClearButton + ? IconButton( + icon: Icon( + Icons.clear, + color: + appBarTheme.iconTheme?.color ?? + colorScheme.onSurface, + ), + onPressed: _textController.clear, + ) + : null, + ), + onSubmitted: (_) => _performSearch(), + ), + ), + ], ), actions: [ - // Search action button IconButton( icon: const Icon(Icons.search), - tooltip: l10n.headlinesSearchActionTooltip, // Re-added tooltip + tooltip: l10n.headlinesSearchActionTooltip, onPressed: _performSearch, ), ], ), body: BlocBuilder( builder: (context, state) { - // Handle different states of the search BLoC return switch (state) { - // Loading state + HeadlinesSearchInitial() => InitialStateWidget( + icon: Icons.search, // Changed icon + headline: l10n.searchPageInitialHeadline, // Use new generic key + subheadline: + l10n.searchPageInitialSubheadline, // Use new generic key + ), + // Use more generic loading text or existing keys HeadlinesSearchLoading() => InitialStateWidget( - icon: Icons.manage_search, // Changed icon + icon: Icons.manage_search, headline: - l10n.headlinesSearchInitialHeadline, // Keep initial text for loading phase - subheadline: l10n.headlinesSearchInitialSubheadline, + l10n.headlinesFeedLoadingHeadline, // Re-use feed loading + subheadline: + 'Searching ${state.selectedModelType.displayName.toLowerCase()}...', ), - // Success state with results HeadlinesSearchSuccess( - :final headlines, - :final hasMore, - :final errorMessage, // Check for specific error message within success - :final lastSearchTerm, + results: final results, + hasMore: final hasMore, + errorMessage: final errorMessage, + lastSearchTerm: final lastSearchTerm, + selectedModelType: final resultsModelType, ) => errorMessage != null - // Display error if present within success state ? FailureStateWidget( message: errorMessage, - onRetry: () { - // Retry with the last successful search term - context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm, + onRetry: + () => context.read().add( + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm, + ), ), - ); - }, ) - // Display "no results" if list is empty - : headlines.isEmpty + : results.isEmpty ? FailureStateWidget( message: - '${l10n.headlinesSearchNoResultsHeadline}\n${l10n.headlinesSearchNoResultsSubheadline}', + '${l10n.headlinesSearchNoResultsHeadline} for "$lastSearchTerm" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', ) - // Display the list of headlines - : ListView.builder( + : ListView.separated( controller: _scrollController, - // Add 1 for loading indicator if more items exist - itemCount: - hasMore ? headlines.length + 1 : headlines.length, + padding: const EdgeInsets.all( + AppSpacing.paddingMedium, + ), // Add overall padding + itemCount: hasMore ? results.length + 1 : results.length, + separatorBuilder: + (context, index) => const SizedBox( + height: AppSpacing.md, + ), // Add separator itemBuilder: (context, index) { - // Show loading indicator at the end if hasMore - if (index >= headlines.length) { - // Ensure loading indicator is visible + if (index >= results.length) { return const Padding( - padding: EdgeInsets.all(AppSpacing.paddingLarge), + padding: EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), // Adjusted padding for loader child: Center(child: CircularProgressIndicator()), ); } - // Display headline item - return HeadlineItemWidget( - headline: headlines[index], - targetRouteName: Routes.searchArticleDetailsName, - ); + final item = results[index]; + switch (resultsModelType) { + case SearchModelType.headline: + return HeadlineItemWidget( + headline: item as Headline, + targetRouteName: Routes.searchArticleDetailsName, + ); + case SearchModelType.category: + return CategoryItemWidget(category: item as Category); + case SearchModelType.source: + return SourceItemWidget(source: item as Source); + case SearchModelType.country: + return CountryItemWidget(country: item as Country); + } }, ), - // Default case (should ideally not be reached if states are handled) + HeadlinesSearchFailure( + errorMessage: final errorMessage, + lastSearchTerm: final lastSearchTerm, + selectedModelType: final failedModelType, + ) => + FailureStateWidget( + message: + 'Failed to search $lastSearchTerm in ${failedModelType.displayName.toLowerCase()}:\n$errorMessage', + onRetry: + () => context.read().add( + HeadlinesSearchFetchRequested(searchTerm: lastSearchTerm), + ), + ), + // Add default case for exhaustiveness _ => const SizedBox.shrink(), }; }, ), ); } + + String _getHintTextForModelType( + SearchModelType modelType, + AppLocalizations l10n, + ) { + switch (modelType) { + case SearchModelType.headline: + return l10n.searchHintTextHeadline; + case SearchModelType.category: + return l10n.searchHintTextCategory; + case SearchModelType.source: + return l10n.searchHintTextSource; + case SearchModelType.country: + return l10n.searchHintTextCountry; + } + } } diff --git a/lib/headlines-search/widgets/category_item_widget.dart b/lib/headlines-search/widgets/category_item_widget.dart new file mode 100644 index 00000000..23b1b083 --- /dev/null +++ b/lib/headlines-search/widgets/category_item_widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:ht_shared/ht_shared.dart'; // Import Category model + +/// A simple widget to display a Category search result. +class CategoryItemWidget extends StatelessWidget { + const CategoryItemWidget({required this.category, super.key}); + + final Category category; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(category.name), + subtitle: + category.description != null + ? Text( + category.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + // TODO(you): Implement onTap navigation if needed for categories + onTap: () { + // Example: Navigate to a filtered feed for this category + // context.goNamed('someCategoryFeedRoute', params: {'id': category.id}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Tapped on category: ${category.name}')), + ); + }, + ); + } +} diff --git a/lib/headlines-search/widgets/country_item_widget.dart b/lib/headlines-search/widgets/country_item_widget.dart new file mode 100644 index 00000000..0e87f6ae --- /dev/null +++ b/lib/headlines-search/widgets/country_item_widget.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:ht_shared/ht_shared.dart'; // Import Country model + +/// A simple widget to display a Country search result. +class CountryItemWidget extends StatelessWidget { + const CountryItemWidget({required this.country, super.key}); + + final Country country; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundImage: NetworkImage(country.flagUrl), + onBackgroundImageError: (exception, stackTrace) { + debugPrint('Error loading country flag: $exception'); + }, + child: + country.flagUrl.isEmpty + ? const Icon( + Icons.public_off_outlined, + ) // Placeholder if no flag + : null, + ), + title: Text(country.name), + // TODO(you): Implement onTap navigation if needed for countries + onTap: () { + // Example: Navigate to a page showing headlines from this country + // context.goNamed('someCountryFeedRoute', params: {'isoCode': country.isoCode}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Tapped on country: ${country.name}')), + ); + }, + ); + } +} diff --git a/lib/headlines-search/widgets/source_item_widget.dart b/lib/headlines-search/widgets/source_item_widget.dart new file mode 100644 index 00000000..35583293 --- /dev/null +++ b/lib/headlines-search/widgets/source_item_widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:ht_shared/ht_shared.dart'; // Import Source model + +/// A simple widget to display a Source search result. +class SourceItemWidget extends StatelessWidget { + const SourceItemWidget({required this.source, super.key}); + + final Source source; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(source.name), + subtitle: + source.description != null + ? Text( + source.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + // TODO(you): Implement onTap navigation if needed for sources + onTap: () { + // Example: Navigate to a page showing headlines from this source + // context.goNamed('someSourceFeedRoute', params: {'id': source.id}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Tapped on source: ${source.name}')), + ); + }, + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index d7c4779e..46fa356b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -589,5 +589,47 @@ "headlinesFeedFilterNoSourcesMatch": "لا توجد مصادر تطابق الفلاتر المحددة.", "@headlinesFeedFilterNoSourcesMatch": { "description": "Message shown when no sources match the selected filters" + }, + + "searchModelTypeHeadline": "العناوين الرئيسية", + "@searchModelTypeHeadline": { + "description": "Dropdown display name for Headline search type" + }, + "searchModelTypeCategory": "الفئات", + "@searchModelTypeCategory": { + "description": "Dropdown display name for Category search type" + }, + "searchModelTypeSource": "المصادر", + "@searchModelTypeSource": { + "description": "Dropdown display name for Source search type" + }, + "searchModelTypeCountry": "الدول", + "@searchModelTypeCountry": { + "description": "Dropdown display name for Country search type" + }, + "searchHintTextHeadline": "مثال: تطورات الذكاء الاصطناعي, مركبة المريخ...", + "@searchHintTextHeadline": { + "description": "Hint text for searching headlines" + }, + "searchHintTextCategory": "مثال: تكنولوجيا, رياضة, مالية...", + "@searchHintTextCategory": { + "description": "Hint text for searching categories" + }, + "searchHintTextSource": "مثال: بي بي سي نيوز, تك كرانش, رويترز...", + "@searchHintTextSource": { + "description": "Hint text for searching sources" + }, + "searchHintTextCountry": "مثال: الولايات المتحدة, اليابان, البرازيل...", + "@searchHintTextCountry": { + "description": "Hint text for searching countries" + }, + + "searchPageInitialHeadline": "ابدأ بحثك", + "@searchPageInitialHeadline": { + "description": "Generic headline for the initial state of the search page" + }, + "searchPageInitialSubheadline": "اختر نوعًا وأدخل كلمات رئيسية للبدء.", + "@searchPageInitialSubheadline": { + "description": "Generic subheadline for the initial state of the search page" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a78e8ea4..cae4c976 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -589,5 +589,47 @@ "headlinesFeedFilterNoSourcesMatch": "No sources match your selected filters.", "@headlinesFeedFilterNoSourcesMatch": { "description": "Message shown when no sources match the selected filters" + }, + + "searchModelTypeHeadline": "Headlines", + "@searchModelTypeHeadline": { + "description": "Dropdown display name for Headline search type" + }, + "searchModelTypeCategory": "Categories", + "@searchModelTypeCategory": { + "description": "Dropdown display name for Category search type" + }, + "searchModelTypeSource": "Sources", + "@searchModelTypeSource": { + "description": "Dropdown display name for Source search type" + }, + "searchModelTypeCountry": "Countries", + "@searchModelTypeCountry": { + "description": "Dropdown display name for Country search type" + }, + "searchHintTextHeadline": "e.g., AI advancements, Mars rover...", + "@searchHintTextHeadline": { + "description": "Hint text for searching headlines" + }, + "searchHintTextCategory": "e.g., Technology, Sports, Finance...", + "@searchHintTextCategory": { + "description": "Hint text for searching categories" + }, + "searchHintTextSource": "e.g., BBC News, TechCrunch, Reuters...", + "@searchHintTextSource": { + "description": "Hint text for searching sources" + }, + "searchHintTextCountry": "e.g., USA, Japan, Brazil...", + "@searchHintTextCountry": { + "description": "Hint text for searching countries" + }, + + "searchPageInitialHeadline": "Start Your Search", + "@searchPageInitialHeadline": { + "description": "Generic headline for the initial state of the search page" + }, + "searchPageInitialSubheadline": "Select a type and enter keywords to begin.", + "@searchPageInitialSubheadline": { + "description": "Generic subheadline for the initial state of the search page" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 52acb83f..39099b66 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -297,6 +297,12 @@ GoRouter createRouter({ (context) => HeadlinesSearchBloc( headlinesRepository: context.read>(), + categoryRepository: + context.read>(), + sourceRepository: + context.read>(), + countryRepository: + context.read>(), ), ), BlocProvider(