Skip to content

Search page model filter UI and logic #22

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions lib/headlines-feed/bloc/headlines_feed_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 1 addition & 2 deletions lib/headlines-feed/bloc/sources_filter_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 0 additions & 5 deletions lib/headlines-feed/bloc/sources_filter_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
1 change: 0 additions & 1 deletion lib/headlines-feed/widgets/headline_item_widget.dart
Original file line number Diff line number Diff line change
@@ -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
Expand Down
160 changes: 120 additions & 40 deletions lib/headlines-search/bloc/headlines_search_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,108 +2,188 @@ 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';
part 'headlines_search_state.dart';

class HeadlinesSearchBloc
extends Bloc<HeadlinesSearchEvent, HeadlinesSearchState> {
HeadlinesSearchBloc({required HtDataRepository<Headline> headlinesRepository})
: _headlinesRepository = headlinesRepository,
super(const HeadlinesSearchInitial()) {
// Start with Initial state
HeadlinesSearchBloc({
required HtDataRepository<Headline> headlinesRepository,
required HtDataRepository<Category> categoryRepository,
required HtDataRepository<Source> sourceRepository,
required HtDataRepository<Country> countryRepository,
}) : _headlinesRepository = headlinesRepository,
_categoryRepository = categoryRepository,
_sourceRepository = sourceRepository,
_countryRepository = countryRepository,
super(const HeadlinesSearchInitial()) {
on<HeadlinesSearchModelTypeChanged>(_onHeadlinesSearchModelTypeChanged);
on<HeadlinesSearchFetchRequested>(
_onSearchFetchRequested,
transformer: restartable(), // Process only the latest search
);
}

final HtDataRepository<Headline> _headlinesRepository;
final HtDataRepository<Category> _categoryRepository;
final HtDataRepository<Source> _sourceRepository;
final HtDataRepository<Country> _countryRepository;
static const _limit = 10;

Future<void> _onHeadlinesSearchModelTypeChanged(
HeadlinesSearchModelTypeChanged event,
Emitter<HeadlinesSearchState> 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<void> _onSearchFetchRequested(
HeadlinesSearchFetchRequested event,
Emitter<HeadlinesSearchState> 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<dynamic> 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<dynamic> 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,
),
);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/headlines-search/bloc/headlines_search_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ sealed class HeadlinesSearchEvent extends Equatable {
List<Object> get props => [];
}

final class HeadlinesSearchModelTypeChanged extends HeadlinesSearchEvent {
const HeadlinesSearchModelTypeChanged(this.newModelType);

final SearchModelType newModelType;

@override
List<Object> get props => [newModelType];
}

final class HeadlinesSearchFetchRequested extends HeadlinesSearchEvent {
const HeadlinesSearchFetchRequested({required this.searchTerm});

Expand Down
41 changes: 26 additions & 15 deletions lib/headlines-search/bloc/headlines_search_state.dart
Original file line number Diff line number Diff line change
@@ -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<Object?> get props => [];
List<Object?> 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<Object?> get props => [lastSearchTerm];
List<Object?> 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<Headline> headlines;
final List<dynamic> 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<Headline>? headlines,
List<dynamic>? 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<Object?> get props => [
headlines,
...super.props,
results,
hasMore,
cursor,
errorMessage,
Expand All @@ -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<Object?> get props => [errorMessage, lastSearchTerm];
List<Object?> get props => [...super.props, errorMessage, lastSearchTerm];
}
Loading
Loading