Skip to content

Feature headlines feed #1

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 11 commits into from
Mar 14, 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
3 changes: 3 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
analyzer:
errors:
avoid_catches_without_on_clauses: ignore
include: package:very_good_analysis/analysis_options.7.0.0.yaml
linter:
rules:
Expand Down
3 changes: 1 addition & 2 deletions l10n.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
arb-dir: lib/l10n/arb
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
synthetic-package: false
nullable-getter: false
1 change: 1 addition & 0 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class _AppView extends StatelessWidget {
return BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
theme:
state.themeMode == ThemeMode.light ? lightTheme() : darkTheme(),
routerConfig: appRouter,
Expand Down
183 changes: 183 additions & 0 deletions lib/headlines-feed/bloc/headlines_feed_bloc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:ht_headlines_repository/ht_headlines_repository.dart';
import 'package:ht_main/headlines-feed/models/headline_filter.dart';

part 'headlines_feed_event.dart';
part 'headlines_feed_state.dart';

/// {@template headlines_feed_bloc}
/// A Bloc that manages the headlines feed.
///
/// It handles fetching and refreshing headlines data using the
/// [HtHeadlinesRepository].
/// {@endtemplate}
class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
/// {@macro headlines_feed_bloc}
HeadlinesFeedBloc({required HtHeadlinesRepository headlinesRepository})
: _headlinesRepository = headlinesRepository,
super(HeadlinesFeedLoading()) {
on<HeadlinesFeedFetchRequested>(
_onHeadlinesFeedFetchRequested,
transformer: sequential(),
);
on<HeadlinesFeedRefreshRequested>(
_onHeadlinesFeedRefreshRequested,
transformer: restartable(),
);
on<HeadlinesFeedFilterChanged>(
_onHeadlinesFeedFilterChanged,
);
}

final HtHeadlinesRepository _headlinesRepository;

Future<void> _onHeadlinesFeedFilterChanged(
HeadlinesFeedFilterChanged event,
Emitter<HeadlinesFeedState> emit,
) async {
emit(HeadlinesFeedLoading());
try {
final response = await _headlinesRepository.getHeadlines(
limit: 20,
category: event.category, // Pass category directly
source: event.source, // Pass source directly
eventCountry: event.eventCountry, // Pass eventCountry directly
);
final newFilter = (state is HeadlinesFeedLoaded)
? (state as HeadlinesFeedLoaded).filter.copyWith(
category: event.category,
source: event.source,
eventCountry: event.eventCountry,
)
: HeadlineFilter(
category: event.category,
source: event.source,
eventCountry: event.eventCountry,
);
emit(
HeadlinesFeedLoaded(
headlines: response.items,
hasMore: response.hasMore,
cursor: response.cursor,
filter: newFilter,
),
);
} on HeadlinesFetchException catch (e) {
emit(HeadlinesFeedError(message: e.message));
} catch (_) {
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
}
}

/// Handles [HeadlinesFeedFetchRequested] events.
///
/// Fetches headlines from the repository and emits
/// [HeadlinesFeedLoading], and either [HeadlinesFeedLoaded] or
/// [HeadlinesFeedError] states.
Future<void> _onHeadlinesFeedFetchRequested(
HeadlinesFeedFetchRequested event,
Emitter<HeadlinesFeedState> emit,
) async {
if (state is HeadlinesFeedLoaded &&
(state as HeadlinesFeedLoaded).hasMore) {
final currentState = state as HeadlinesFeedLoaded;
emit(HeadlinesFeedLoading());
try {
final response = await _headlinesRepository.getHeadlines(
limit: 20,
startAfterId: currentState.cursor,
category: currentState.filter.category, // Use existing filter
source: currentState.filter.source, // Use existing filter
eventCountry: currentState.filter.eventCountry, // Use existing filter
);
emit(
HeadlinesFeedLoaded(
headlines: currentState.headlines + response.items,
hasMore: response.hasMore,
cursor: response.cursor,
filter: currentState.filter,
),
);
} on HeadlinesFetchException catch (e) {
emit(HeadlinesFeedError(message: e.message));
} catch (_) {
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
}
} else {
emit(HeadlinesFeedLoading());
try {
final response = await _headlinesRepository.getHeadlines(
limit: 20,
category: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter.category
: null,
source: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter.source
: null,
eventCountry: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter.eventCountry
: null,
);
emit(
HeadlinesFeedLoaded(
headlines: response.items,
hasMore: response.hasMore,
cursor: response.cursor,
filter: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter
: const HeadlineFilter(),
),
);
} on HeadlinesFetchException catch (e) {
emit(HeadlinesFeedError(message: e.message));
} catch (_) {
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
}
}
}

/// Handles [HeadlinesFeedRefreshRequested] events.
///
/// Fetches headlines from the repository and emits
/// [HeadlinesFeedLoading], and either [HeadlinesFeedLoaded] or
/// [HeadlinesFeedError] states.
///
/// Uses `restartable` transformer to ensure that only the latest
/// refresh request is processed.
Future<void> _onHeadlinesFeedRefreshRequested(
HeadlinesFeedRefreshRequested event,
Emitter<HeadlinesFeedState> emit,
) async {
emit(HeadlinesFeedLoading());
try {
final response = await _headlinesRepository.getHeadlines(
limit: 20,
category: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter.category
: null,
source: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter.source
: null,
eventCountry: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter.eventCountry
: null,
);
emit(
HeadlinesFeedLoaded(
headlines: response.items,
hasMore: response.hasMore,
cursor: response.cursor,
filter: state is HeadlinesFeedLoaded
? (state as HeadlinesFeedLoaded).filter
: const HeadlineFilter(),
),
);
} on HeadlinesFetchException catch (e) {
emit(HeadlinesFeedError(message: e.message));
} catch (_) {
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
}
}
}
55 changes: 55 additions & 0 deletions lib/headlines-feed/bloc/headlines_feed_event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
part of 'headlines_feed_bloc.dart';

/// {@template headlines_feed_event}
/// Base class for all events related to the headlines feed.
/// {@endtemplate}
sealed class HeadlinesFeedEvent extends Equatable {
/// {@macro headlines_feed_event}
const HeadlinesFeedEvent();

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

/// {@template headlines_feed_fetch_requested}
/// Event triggered when the headlines feed needs to be fetched.
/// {@endtemplate}
final class HeadlinesFeedFetchRequested extends HeadlinesFeedEvent {
/// {@macro headlines_feed_fetch_requested}
const HeadlinesFeedFetchRequested({this.cursor});

/// The cursor for pagination.
final String? cursor;

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

/// {@template headlines_feed_refresh_requested}
/// Event triggered when the headlines feed needs to be refreshed.
/// {@endtemplate}
final class HeadlinesFeedRefreshRequested extends HeadlinesFeedEvent {}

/// {@template headlines_feed_filter_changed}
/// Event triggered when the filter parameters for the headlines feed change.
/// {@endtemplate}
final class HeadlinesFeedFilterChanged extends HeadlinesFeedEvent {
/// {@macro headlines_feed_filter_changed}
const HeadlinesFeedFilterChanged({
this.category,
this.source,
this.eventCountry,
});

/// The selected category filter.
final String? category;

/// The selected source filter.
final String? source;

/// The selected event country filter.
final String? eventCountry;

@override
List<Object?> get props => [category, source, eventCountry];
}
76 changes: 76 additions & 0 deletions lib/headlines-feed/bloc/headlines_feed_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
part of 'headlines_feed_bloc.dart';

/// {@template headlines_feed_state}
/// Base class for all states related to the headlines feed.
/// {@endtemplate}
sealed class HeadlinesFeedState extends Equatable {
/// {@macro headlines_feed_state}
const HeadlinesFeedState();

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

/// {@template headlines_feed_loading}
/// State indicating that the headlines feed is being loaded.
/// {@endtemplate}
final class HeadlinesFeedLoading extends HeadlinesFeedState {}

/// {@template headlines_feed_loaded}
/// State indicating that the headlines feed has been loaded successfully,
/// potentially with applied filters.
/// {@endtemplate}
final class HeadlinesFeedLoaded extends HeadlinesFeedState {
/// {@macro headlines_feed_loaded}
const HeadlinesFeedLoaded({
this.headlines = const [],
this.hasMore = true,
this.cursor,
this.filter = const HeadlineFilter(),
});

/// The headlines data.
final List<Headline> headlines;

/// Indicates if there are more headlines.
final bool hasMore;

/// The cursor for the next page.
final String? cursor;

/// The filter applied to the headlines.
final HeadlineFilter filter;

/// Creates a copy of this [HeadlinesFeedLoaded] with the given fields
/// replaced with the new values.
HeadlinesFeedLoaded copyWith({
List<Headline>? headlines,
bool? hasMore,
String? cursor,
HeadlineFilter? filter,
}) {
return HeadlinesFeedLoaded(
headlines: headlines ?? this.headlines,
hasMore: hasMore ?? this.hasMore,
cursor: cursor ?? this.cursor,
filter: filter ?? this.filter,
);
}

@override
List<Object?> get props => [headlines, hasMore, cursor, filter];
}

/// {@template headlines_feed_error}
/// State indicating that an error occurred while loading the headlines feed.
/// {@endtemplate}
final class HeadlinesFeedError extends HeadlinesFeedState {
/// {@macro headlines_feed_error}
const HeadlinesFeedError({required this.message});

/// The error message.
final String message;

@override
List<Object> get props => [message];
}
39 changes: 39 additions & 0 deletions lib/headlines-feed/models/headline_filter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';

/// {@template headline_filter}
/// A model representing the filter parameters for headlines.
/// {@endtemplate}
class HeadlineFilter extends Equatable {
/// {@macro headline_filter}
const HeadlineFilter({
this.category,
this.source,
this.eventCountry,
});

/// The selected category filter.
final String? category;

/// The selected source filter.
final String? source;

/// The selected event country filter.
final String? eventCountry;

@override
List<Object?> get props => [category, source, eventCountry];

/// Creates a copy of this [HeadlineFilter] with the given fields
/// replaced with the new values.
HeadlineFilter copyWith({
String? category,
String? source,
String? eventCountry,
}) {
return HeadlineFilter(
category: category ?? this.category,
source: source ?? this.source,
eventCountry: eventCountry ?? this.eventCountry,
);
}
}
Loading
Loading