From 5beb7b68b44c8f761cdeefbb15b1b4efd28dacd6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 07:34:27 +0100 Subject: [PATCH 01/11] chore --- l10n.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/l10n.yaml b/l10n.yaml index e904a97d..f88aa438 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -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 \ No newline at end of file From 5ee94d083f7995feee85ed8a777752db419b096c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 07:38:52 +0100 Subject: [PATCH 02/11] feat: Add HeadlinesFeedPage and update routes - Created HeadlinesFeedPage - Updated routes to use it --- .../view/headlines_feed_page.dart} | 4 ++-- lib/router/router.dart | 8 ++++---- lib/router/routes.dart | 2 +- pubspec.yaml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename lib/{headlines/view/headlines_page.dart => headlines-feed/view/headlines_feed_page.dart} (63%) diff --git a/lib/headlines/view/headlines_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart similarity index 63% rename from lib/headlines/view/headlines_page.dart rename to lib/headlines-feed/view/headlines_feed_page.dart index c0b10f2f..303351f3 100644 --- a/lib/headlines/view/headlines_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -class HeadlinesPage extends StatelessWidget { - const HeadlinesPage({super.key}); +class HeadlinesFeedPage extends StatelessWidget { + const HeadlinesFeedPage({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/router/router.dart b/lib/router/router.dart index ef3e6c41..dd9eb94e 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_main/app/view/app_scaffold.dart'; -import 'package:ht_main/headlines/view/headlines_page.dart'; +import 'package:ht_main/headlines-feed/view/headlines_feed_page.dart'; import 'package:ht_main/router/routes.dart'; final appRouter = GoRouter( - initialLocation: Routes.headlines, + initialLocation: Routes.headlinesFeed, routes: [ ShellRoute( builder: (context, state, child) { @@ -13,9 +13,9 @@ final appRouter = GoRouter( }, routes: [ GoRoute( - path: Routes.headlines, + path: Routes.headlinesFeed, builder: (BuildContext context, GoRouterState state) { - return const HeadlinesPage(); + return const HeadlinesFeedPage(); }, ), GoRoute( diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 8eac5ec8..d90c80fb 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -1,6 +1,6 @@ abstract final class Routes { static const home = '/'; - static const headlines = '/headlines'; + static const headlinesFeed = '/headlines-feed'; static const search = '/search'; static const account = '/account'; } diff --git a/pubspec.yaml b/pubspec.yaml index 9e32c01f..003dc230 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.4.5 +version: 0.5.5 publish_to: none repository: https://github.com/Headlines-Toolkit/ht-main environment: From 39e76b6f81e1aa852df720a5d774773dcdcd2360 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 08:56:33 +0100 Subject: [PATCH 03/11] feat: Implement HeadlinesFeedBloc - Added HeadlinesFeedBloc - Manages headlines feed state - Fetches headlines from repository --- analysis_options.yaml | 3 + .../bloc/headlines_feed_bloc.dart | 109 ++++++++++++++++++ .../bloc/headlines_feed_event.dart | 22 ++++ .../bloc/headlines_feed_state.dart | 60 ++++++++++ pubspec.lock | 16 +++ pubspec.yaml | 5 +- 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/headlines-feed/bloc/headlines_feed_bloc.dart create mode 100644 lib/headlines-feed/bloc/headlines_feed_event.dart create mode 100644 lib/headlines-feed/bloc/headlines_feed_state.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index c59316b7..108907b2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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: diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart new file mode 100644 index 00000000..e38defa2 --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -0,0 +1,109 @@ +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'; + +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 { + /// {@macro headlines_feed_bloc} + HeadlinesFeedBloc({required HtHeadlinesRepository headlinesRepository}) + : _headlinesRepository = headlinesRepository, + super(HeadlinesFeedInitial()) { + on( + _onHeadlinesFeedFetchRequested, + transformer: sequential(), + ); + on( + _onHeadlinesFeedRefreshRequested, + transformer: restartable(), + ); + } + + final HtHeadlinesRepository _headlinesRepository; + + /// Handles [HeadlinesFeedFetchRequested] events. + /// + /// Fetches headlines from the repository and emits + /// [HeadlinesFeedLoading], and either [HeadlinesFeedLoaded] or + /// [HeadlinesFeedError] states. + Future _onHeadlinesFeedFetchRequested( + HeadlinesFeedFetchRequested event, + Emitter 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, + ); + emit( + HeadlinesFeedLoaded( + headlines: currentState.headlines + response.items, + hasMore: response.hasMore, + cursor: response.cursor, + ), + ); + } 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); + emit( + HeadlinesFeedLoaded( + headlines: response.items, + hasMore: response.hasMore, + cursor: response.cursor, + ), + ); + } 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 _onHeadlinesFeedRefreshRequested( + HeadlinesFeedRefreshRequested event, + Emitter emit, + ) async { + emit(HeadlinesFeedLoading()); + try { + final response = await _headlinesRepository.getHeadlines(limit: 20); + emit( + HeadlinesFeedLoaded( + headlines: response.items, + hasMore: response.hasMore, + cursor: response.cursor, + ), + ); + } on HeadlinesFetchException catch (e) { + emit(HeadlinesFeedError(message: e.message)); + } catch (_) { + emit(const HeadlinesFeedError(message: 'An unexpected error occurred')); + } + } +} diff --git a/lib/headlines-feed/bloc/headlines_feed_event.dart b/lib/headlines-feed/bloc/headlines_feed_event.dart new file mode 100644 index 00000000..bc8c15b8 --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_feed_event.dart @@ -0,0 +1,22 @@ +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 get props => []; +} + +/// {@template headlines_feed_fetch_requested} +/// Event triggered when the headlines feed needs to be fetched. +/// {@endtemplate} +final class HeadlinesFeedFetchRequested extends HeadlinesFeedEvent {} + +/// {@template headlines_feed_refresh_requested} +/// Event triggered when the headlines feed needs to be refreshed. +/// {@endtemplate} +final class HeadlinesFeedRefreshRequested extends HeadlinesFeedEvent {} diff --git a/lib/headlines-feed/bloc/headlines_feed_state.dart b/lib/headlines-feed/bloc/headlines_feed_state.dart new file mode 100644 index 00000000..f931aacd --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_feed_state.dart @@ -0,0 +1,60 @@ +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 get props => []; +} + +/// {@template headlines_feed_initial} +/// The initial state of the headlines feed. +/// {@endtemplate} +final class HeadlinesFeedInitial extends HeadlinesFeedState {} + +/// {@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. +/// {@endtemplate} +final class HeadlinesFeedLoaded extends HeadlinesFeedState { + /// {@macro headlines_feed_loaded} + const HeadlinesFeedLoaded({ + required this.headlines, + required this.hasMore, + this.cursor, + }); + + /// The headlines data. + final List headlines; + + /// Indicates if there are more headlines. + final bool hasMore; + + /// The cursor for the next page. + final String? cursor; + + @override + List get props => [headlines, hasMore, cursor ?? '']; +} + +/// {@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 get props => [message]; +} diff --git a/pubspec.lock b/pubspec.lock index 525a90fb..7ac84724 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + bloc_concurrency: + dependency: "direct main" + description: + name: bloc_concurrency + sha256: "86b7b17a0a78f77fca0d7c030632b59b593b22acea2d96972588f40d4ef53a94" + url: "https://pub.dev" + source: hosted + version: "0.3.0" bloc_test: dependency: "direct dev" description: @@ -598,6 +606,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 003dc230..a13d6f07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,14 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.5.5 +version: 0.6.5 publish_to: none -repository: https://github.com/Headlines-Toolkit/ht-main +repository: https://github.com/headlines-toolkit/ht-main environment: sdk: ^3.5.0 dependencies: bloc: ^9.0.0 + bloc_concurrency: ^0.3.0 equatable: ^2.0.7 flex_color_scheme: ^8.1.1 flutter: From d1f74bb88cf6195c432afadc56cef2e038bb9fae Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 09:23:01 +0100 Subject: [PATCH 04/11] feat: Add HeadlineItem and state widgets - Added HeadlineItemWidget - Added FailureStateWidget - Added LoadingStateWidget - Added InitialStateWidget --- .../view/headlines_feed_page.dart | 86 ++++++++++++++++++- .../widgets/headline_item_widget.dart | 36 ++++++++ lib/shared/widgets/failure_state_widget.dart | 44 ++++++++++ lib/shared/widgets/initial_state_widget.dart | 12 +++ lib/shared/widgets/loading_state_widget.dart | 14 +++ pubspec.yaml | 2 +- 6 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 lib/headlines-feed/widgets/headline_item_widget.dart create mode 100644 lib/shared/widgets/failure_state_widget.dart create mode 100644 lib/shared/widgets/initial_state_widget.dart create mode 100644 lib/shared/widgets/loading_state_widget.dart diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 303351f3..a1dced2f 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -1,10 +1,92 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; +import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; +import 'package:ht_main/shared/widgets/failure_state_widget.dart'; +import 'package:ht_main/shared/widgets/initial_state_widget.dart'; +import 'package:ht_main/shared/widgets/loading_state_widget.dart'; -class HeadlinesFeedPage extends StatelessWidget { +class HeadlinesFeedPage extends StatefulWidget { const HeadlinesFeedPage({super.key}); + @override + State createState() => _HeadlinesFeedPageState(); +} + +class _HeadlinesFeedPageState extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) { + context.read().add(HeadlinesFeedFetchRequested()); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.9); + } + @override Widget build(BuildContext context) { - return const Placeholder(); // Placeholder for now + return Scaffold( + appBar: AppBar(title: const Text('Headlines Feed')), + body: BlocBuilder( + builder: (context, state) { + switch (state) { + case HeadlinesFeedInitial(): + return const InitialStateWidget(); + case HeadlinesFeedLoading(): + return const LoadingStateWidget(); + case HeadlinesFeedLoaded(): + return RefreshIndicator( + onRefresh: () async { + context + .read() + .add(HeadlinesFeedRefreshRequested()); + }, + child: ListView.builder( + controller: _scrollController, + itemCount: state.hasMore + ? state.headlines.length + 1 + : state.headlines.length, + itemBuilder: (context, index) { + if (index >= state.headlines.length) { + return const LoadingStateWidget(); + } + final headline = state.headlines[index]; + return HeadlineItemWidget(headline: headline); + }, + ), + ); + case HeadlinesFeedError(): + return FailureStateWidget( + message: state.message, + onRetry: () { + context + .read() + .add(HeadlinesFeedRefreshRequested()); + }, + ); + } + }, + ), + ); } } diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart new file mode 100644 index 00000000..899a723c --- /dev/null +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:ht_headlines_repository/ht_headlines_repository.dart' + show Headline; + +/// A widget that displays a single headline. +class HeadlineItemWidget extends StatelessWidget { + /// Creates a [HeadlineItemWidget]. + const HeadlineItemWidget({required this.headline, super.key}); + + /// The headline to display. + final Headline headline; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headline.title ?? 'No Title', + style: Theme.of(context).textTheme.titleMedium, + ), + if (headline.description != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + headline.description!, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart new file mode 100644 index 00000000..83e04724 --- /dev/null +++ b/lib/shared/widgets/failure_state_widget.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// A widget to display an error message and an optional retry button. +class FailureStateWidget extends StatelessWidget { + /// Creates a [FailureStateWidget]. + /// + /// The [message] is the error message to display. + /// The [onRetry] is an optional callback to be called when the retry button is pressed. + const FailureStateWidget({ + required this.message, super.key, + this.onRetry, + }); + + /// The error message to display. + final String message; + + /// An optional callback to be called when the retry button is pressed. + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + // Show the retry button only if onRetry is provided + if (onRetry != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: ElevatedButton( + onPressed: onRetry, + child: const Text('Retry'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/initial_state_widget.dart b/lib/shared/widgets/initial_state_widget.dart new file mode 100644 index 00000000..f8950b26 --- /dev/null +++ b/lib/shared/widgets/initial_state_widget.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class InitialStateWidget extends StatelessWidget { + const InitialStateWidget({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('Initial State'), + ); + } +} diff --git a/lib/shared/widgets/loading_state_widget.dart b/lib/shared/widgets/loading_state_widget.dart new file mode 100644 index 00000000..314690dc --- /dev/null +++ b/lib/shared/widgets/loading_state_widget.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class LoadingStateWidget extends StatelessWidget { + const LoadingStateWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.secondary, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a13d6f07..a23b9632 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.6.5 +version: 0.7.5 publish_to: none repository: https://github.com/headlines-toolkit/ht-main environment: From 3ad8f6ebd8c78fe9229a253476f9f065576def6d Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 09:47:02 +0100 Subject: [PATCH 05/11] feat(feed): implement feed pagination - Added cursor based pagination - Implemented scroll listener - Handled initial refresh --- .../bloc/headlines_feed_event.dart | 11 ++++++- .../view/headlines_feed_page.dart | 29 ++++++++++++++++--- .../widgets/headline_item_widget.dart | 2 +- lib/shared/widgets/failure_state_widget.dart | 7 +++-- pubspec.yaml | 2 +- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/lib/headlines-feed/bloc/headlines_feed_event.dart b/lib/headlines-feed/bloc/headlines_feed_event.dart index bc8c15b8..773ab4b7 100644 --- a/lib/headlines-feed/bloc/headlines_feed_event.dart +++ b/lib/headlines-feed/bloc/headlines_feed_event.dart @@ -14,7 +14,16 @@ sealed class HeadlinesFeedEvent extends Equatable { /// {@template headlines_feed_fetch_requested} /// Event triggered when the headlines feed needs to be fetched. /// {@endtemplate} -final class HeadlinesFeedFetchRequested extends HeadlinesFeedEvent {} +final class HeadlinesFeedFetchRequested extends HeadlinesFeedEvent { + /// {@macro headlines_feed_fetch_requested} + const HeadlinesFeedFetchRequested({this.cursor}); + + /// The cursor for pagination. + final String? cursor; + + @override + List get props => [cursor ?? '']; +} /// {@template headlines_feed_refresh_requested} /// Event triggered when the headlines feed needs to be refreshed. diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index a1dced2f..3518f680 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -1,25 +1,41 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_headlines_repository/ht_headlines_repository.dart'; import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; import 'package:ht_main/shared/widgets/failure_state_widget.dart'; import 'package:ht_main/shared/widgets/initial_state_widget.dart'; import 'package:ht_main/shared/widgets/loading_state_widget.dart'; -class HeadlinesFeedPage extends StatefulWidget { +class HeadlinesFeedPage extends StatelessWidget { const HeadlinesFeedPage({super.key}); @override - State createState() => _HeadlinesFeedPageState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => HeadlinesFeedBloc( + headlinesRepository: context.read(), + )..add(const HeadlinesFeedFetchRequested()), + child: const _HeadlinesFeedView(), + ); + } +} + +class _HeadlinesFeedView extends StatefulWidget { + const _HeadlinesFeedView(); + + @override + State<_HeadlinesFeedView> createState() => _HeadlinesFeedViewState(); } -class _HeadlinesFeedPageState extends State { +class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { final _scrollController = ScrollController(); @override void initState() { super.initState(); _scrollController.addListener(_onScroll); + context.read().add(HeadlinesFeedRefreshRequested()); } @override @@ -32,7 +48,12 @@ class _HeadlinesFeedPageState extends State { void _onScroll() { if (_isBottom) { - context.read().add(HeadlinesFeedFetchRequested()); + final state = context.read().state; + if (state is HeadlinesFeedLoaded) { + context + .read() + .add(HeadlinesFeedFetchRequested(cursor: state.cursor)); + } } } diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart index 899a723c..5a17b945 100644 --- a/lib/headlines-feed/widgets/headline_item_widget.dart +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -18,7 +18,7 @@ class HeadlineItemWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - headline.title ?? 'No Title', + headline.title, style: Theme.of(context).textTheme.titleMedium, ), if (headline.description != null) diff --git a/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart index 83e04724..7d34063d 100644 --- a/lib/shared/widgets/failure_state_widget.dart +++ b/lib/shared/widgets/failure_state_widget.dart @@ -5,9 +5,12 @@ class FailureStateWidget extends StatelessWidget { /// Creates a [FailureStateWidget]. /// /// The [message] is the error message to display. - /// The [onRetry] is an optional callback to be called when the retry button is pressed. + /// + /// The [onRetry] is an optional callback to be called + /// when the retry button is pressed. const FailureStateWidget({ - required this.message, super.key, + required this.message, + super.key, this.onRetry, }); diff --git a/pubspec.yaml b/pubspec.yaml index a23b9632..e8c419aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.7.5 +version: 0.8.5 publish_to: none repository: https://github.com/headlines-toolkit/ht-main environment: From 36b82e2e806b5b75d1f04e0e6c33e8b42ac0f3d5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 10:45:49 +0100 Subject: [PATCH 06/11] feat(headlines): Improve headline item UI - Display headline in a card - Added image and source info - Improved visual appeal --- .../widgets/headline_item_widget.dart | 47 ++++++++++++++----- lib/shared/widgets/failure_state_widget.dart | 4 +- pubspec.yaml | 2 +- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart index 5a17b945..efa3e090 100644 --- a/lib/headlines-feed/widgets/headline_item_widget.dart +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -14,22 +14,45 @@ class HeadlineItemWidget extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + child: Card( + child: ListTile( + leading: Image.network( + headline.imageUrl ?? + 'https://via.placeholder.com/50x50', // Placeholder image + width: 50, + height: 50, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.error), + ), + title: Text( headline.title, style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - if (headline.description != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - headline.description!, - style: Theme.of(context).textTheme.bodySmall, - ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Icon(Icons.source, + color: Theme.of(context) + .iconTheme + .color,), // Placeholder for source icon + const SizedBox(width: 16), + Icon(Icons.category, + color: Theme.of(context) + .iconTheme + .color,), // Placeholder for category icon + const SizedBox(width: 16), + Icon(Icons.location_on, + color: Theme.of(context) + .iconTheme + .color,), // Placeholder for country icon + ], ), - ], + ), + ), ), ); } diff --git a/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart index 7d34063d..4dd54dcc 100644 --- a/lib/shared/widgets/failure_state_widget.dart +++ b/lib/shared/widgets/failure_state_widget.dart @@ -5,8 +5,8 @@ class FailureStateWidget extends StatelessWidget { /// Creates a [FailureStateWidget]. /// /// The [message] is the error message to display. - /// - /// The [onRetry] is an optional callback to be called + /// + /// The [onRetry] is an optional callback to be called /// when the retry button is pressed. const FailureStateWidget({ required this.message, diff --git a/pubspec.yaml b/pubspec.yaml index e8c419aa..0f53a6fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.8.5 +version: 0.9.5 publish_to: none repository: https://github.com/headlines-toolkit/ht-main environment: From 8f9f611d5d02ba15b5890c3b71cb51e2b950b377 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 15:25:42 +0100 Subject: [PATCH 07/11] feat: implement headline filter bottom sheet - Added HeadlineFilter model - Created filter bottom sheet UI - Implemented filter apply logic --- lib/app/view/app.dart | 1 + .../bloc/headlines_feed_bloc.dart | 80 ++++++++++++- .../bloc/headlines_feed_event.dart | 28 ++++- .../bloc/headlines_feed_state.dart | 36 ++++-- .../models/headline_filter.dart | 39 ++++++ .../view/headline_filter_bottom_sheet.dart | 113 ++++++++++++++++++ .../view/headlines_feed_page.dart | 38 +++++- .../widgets/headline_item_widget.dart | 24 ++-- 8 files changed, 327 insertions(+), 32 deletions(-) create mode 100644 lib/headlines-feed/models/headline_filter.dart create mode 100644 lib/headlines-feed/view/headline_filter_bottom_sheet.dart diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 0297fd20..f04501c0 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -39,6 +39,7 @@ class _AppView extends StatelessWidget { return BlocBuilder( builder: (context, state) { return MaterialApp.router( + debugShowCheckedModeBanner: false, theme: state.themeMode == ThemeMode.light ? lightTheme() : darkTheme(), routerConfig: appRouter, diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index e38defa2..95154d44 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_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_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'; @@ -16,7 +17,7 @@ class HeadlinesFeedBloc extends Bloc { /// {@macro headlines_feed_bloc} HeadlinesFeedBloc({required HtHeadlinesRepository headlinesRepository}) : _headlinesRepository = headlinesRepository, - super(HeadlinesFeedInitial()) { + super(HeadlinesFeedLoading()) { on( _onHeadlinesFeedFetchRequested, transformer: sequential(), @@ -25,10 +26,51 @@ class HeadlinesFeedBloc extends Bloc { _onHeadlinesFeedRefreshRequested, transformer: restartable(), ); + on( + _onHeadlinesFeedFilterChanged, + ); } final HtHeadlinesRepository _headlinesRepository; + Future _onHeadlinesFeedFilterChanged( + HeadlinesFeedFilterChanged event, + Emitter 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 @@ -46,12 +88,16 @@ class HeadlinesFeedBloc extends Bloc { 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) { @@ -62,12 +108,26 @@ class HeadlinesFeedBloc extends Bloc { } else { emit(HeadlinesFeedLoading()); try { - final response = await _headlinesRepository.getHeadlines(limit: 20); + 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) { @@ -92,12 +152,26 @@ class HeadlinesFeedBloc extends Bloc { ) async { emit(HeadlinesFeedLoading()); try { - final response = await _headlinesRepository.getHeadlines(limit: 20); + 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) { diff --git a/lib/headlines-feed/bloc/headlines_feed_event.dart b/lib/headlines-feed/bloc/headlines_feed_event.dart index 773ab4b7..ab09590e 100644 --- a/lib/headlines-feed/bloc/headlines_feed_event.dart +++ b/lib/headlines-feed/bloc/headlines_feed_event.dart @@ -8,7 +8,7 @@ sealed class HeadlinesFeedEvent extends Equatable { const HeadlinesFeedEvent(); @override - List get props => []; + List get props => []; } /// {@template headlines_feed_fetch_requested} @@ -22,10 +22,34 @@ final class HeadlinesFeedFetchRequested extends HeadlinesFeedEvent { final String? cursor; @override - List get props => [cursor ?? '']; + List 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 get props => [category, source, eventCountry]; +} diff --git a/lib/headlines-feed/bloc/headlines_feed_state.dart b/lib/headlines-feed/bloc/headlines_feed_state.dart index f931aacd..fae510eb 100644 --- a/lib/headlines-feed/bloc/headlines_feed_state.dart +++ b/lib/headlines-feed/bloc/headlines_feed_state.dart @@ -8,28 +8,25 @@ sealed class HeadlinesFeedState extends Equatable { const HeadlinesFeedState(); @override - List get props => []; + List get props => []; } -/// {@template headlines_feed_initial} -/// The initial state of the headlines feed. -/// {@endtemplate} -final class HeadlinesFeedInitial extends HeadlinesFeedState {} - /// {@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. +/// 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({ - required this.headlines, - required this.hasMore, + this.headlines = const [], + this.hasMore = true, this.cursor, + this.filter = const HeadlineFilter(), }); /// The headlines data. @@ -41,8 +38,27 @@ final class HeadlinesFeedLoaded extends HeadlinesFeedState { /// 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? 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 get props => [headlines, hasMore, cursor ?? '']; + List get props => [headlines, hasMore, cursor, filter]; } /// {@template headlines_feed_error} diff --git a/lib/headlines-feed/models/headline_filter.dart b/lib/headlines-feed/models/headline_filter.dart new file mode 100644 index 00000000..82891fef --- /dev/null +++ b/lib/headlines-feed/models/headline_filter.dart @@ -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 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, + ); + } +} diff --git a/lib/headlines-feed/view/headline_filter_bottom_sheet.dart b/lib/headlines-feed/view/headline_filter_bottom_sheet.dart new file mode 100644 index 00000000..c2505293 --- /dev/null +++ b/lib/headlines-feed/view/headline_filter_bottom_sheet.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; + +class HeadlineFilterBottomSheet extends StatefulWidget { + const HeadlineFilterBottomSheet({ + required this.onApplyFilters, + required this.bloc, + super.key, + }); + + final void Function( + String? category, + String? source, + String? eventCountry, + ) onApplyFilters; + + final HeadlinesFeedBloc bloc; + + @override + State createState() => + _HeadlineFilterBottomSheetState(); +} + +class _HeadlineFilterBottomSheetState extends State { + String? _selectedCategory; + String? _selectedSource; + String? _selectedEventCountry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Filter Headlines', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + // Category Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Category'), + value: _selectedCategory, + items: const [ + // Placeholder items + DropdownMenuItem(value: 'technology', child: Text('Technology')), + DropdownMenuItem(value: 'business', child: Text('Business')), + DropdownMenuItem(value: 'Politics', child: Text('Sports')), + ], + onChanged: (value) { + setState(() { + _selectedCategory = value; + }); + }, + ), + const SizedBox(height: 16), + // Source Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Source'), + value: _selectedSource, + items: const [ + // Placeholder items + DropdownMenuItem(value: 'cnn', child: Text('CNN')), + DropdownMenuItem(value: 'reuters', child: Text('Reuters')), + ], + onChanged: (value) { + setState(() { + _selectedSource = value; + }); + }, + ), + const SizedBox(height: 16), + // Event Country Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Event Country'), + value: _selectedEventCountry, + items: const [ + // Placeholder items + DropdownMenuItem(value: 'US', child: Text('United States')), + DropdownMenuItem(value: 'UK', child: Text('United Kingdom')), + DropdownMenuItem(value: 'CA', child: Text('Canada')), + ], + onChanged: (value) { + setState(() { + _selectedEventCountry = value; + }); + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + widget.onApplyFilters( + _selectedCategory, + _selectedSource, + _selectedEventCountry, + ); + widget.bloc.add( + HeadlinesFeedFilterChanged( + category: _selectedCategory, + source: _selectedSource, + eventCountry: _selectedEventCountry, + ), + ); + Navigator.pop(context); + }, + child: const Text('Apply Filters'), + ), + ], + ), + ); + } +} diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 3518f680..d292814e 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_headlines_repository/ht_headlines_repository.dart'; import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; +import 'package:ht_main/headlines-feed/view/headline_filter_bottom_sheet.dart'; import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; import 'package:ht_main/shared/widgets/failure_state_widget.dart'; -import 'package:ht_main/shared/widgets/initial_state_widget.dart'; import 'package:ht_main/shared/widgets/loading_state_widget.dart'; class HeadlinesFeedPage extends StatelessWidget { @@ -52,7 +52,7 @@ class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { if (state is HeadlinesFeedLoaded) { context .read() - .add(HeadlinesFeedFetchRequested(cursor: state.cursor)); + .add(const HeadlinesFeedFetchRequested()); } } } @@ -67,12 +67,40 @@ class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Headlines Feed')), + appBar: AppBar( + title: const Text('Headlines Feed'), + actions: [ + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + final bloc = context.read(); + return BlocProvider.value( + value: bloc, + child: HeadlineFilterBottomSheet( + bloc: bloc, + onApplyFilters: (category, source, eventCountry) { + bloc.add( + HeadlinesFeedFilterChanged( + category: category, + source: source, + eventCountry: eventCountry, + ), + ); + }, + ), + ); + }, + ); + }, + ), + ], + ), body: BlocBuilder( builder: (context, state) { switch (state) { - case HeadlinesFeedInitial(): - return const InitialStateWidget(); case HeadlinesFeedLoading(): return const LoadingStateWidget(); case HeadlinesFeedLoaded(): diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart index efa3e090..a0a250b6 100644 --- a/lib/headlines-feed/widgets/headline_item_widget.dart +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -35,20 +35,20 @@ class HeadlineItemWidget extends StatelessWidget { padding: const EdgeInsets.only(top: 8), child: Row( children: [ - Icon(Icons.source, - color: Theme.of(context) - .iconTheme - .color,), // Placeholder for source icon + Icon( + Icons.source, + color: Theme.of(context).iconTheme.color, + ), // Placeholder for source icon const SizedBox(width: 16), - Icon(Icons.category, - color: Theme.of(context) - .iconTheme - .color,), // Placeholder for category icon + Icon( + Icons.category, + color: Theme.of(context).iconTheme.color, + ), // Placeholder for category icon const SizedBox(width: 16), - Icon(Icons.location_on, - color: Theme.of(context) - .iconTheme - .color,), // Placeholder for country icon + Icon( + Icons.location_on, + color: Theme.of(context).iconTheme.color, + ), // Placeholder for country icon ], ), ), From 4f7c9e750d4f3576c4730b63489de20d6a07b845 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 15:53:42 +0100 Subject: [PATCH 08/11] refactor: move filter bottom sheet inline - Removed separate filter file - Simplified filter logic - Kept state inside bottom sheet --- .../view/headline_filter_bottom_sheet.dart | 113 --------------- .../view/headlines_feed_page.dart | 132 +++++++++++++++--- pubspec.yaml | 2 +- 3 files changed, 117 insertions(+), 130 deletions(-) delete mode 100644 lib/headlines-feed/view/headline_filter_bottom_sheet.dart diff --git a/lib/headlines-feed/view/headline_filter_bottom_sheet.dart b/lib/headlines-feed/view/headline_filter_bottom_sheet.dart deleted file mode 100644 index c2505293..00000000 --- a/lib/headlines-feed/view/headline_filter_bottom_sheet.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; - -class HeadlineFilterBottomSheet extends StatefulWidget { - const HeadlineFilterBottomSheet({ - required this.onApplyFilters, - required this.bloc, - super.key, - }); - - final void Function( - String? category, - String? source, - String? eventCountry, - ) onApplyFilters; - - final HeadlinesFeedBloc bloc; - - @override - State createState() => - _HeadlineFilterBottomSheetState(); -} - -class _HeadlineFilterBottomSheetState extends State { - String? _selectedCategory; - String? _selectedSource; - String? _selectedEventCountry; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Filter Headlines', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - // Category Dropdown - DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Category'), - value: _selectedCategory, - items: const [ - // Placeholder items - DropdownMenuItem(value: 'technology', child: Text('Technology')), - DropdownMenuItem(value: 'business', child: Text('Business')), - DropdownMenuItem(value: 'Politics', child: Text('Sports')), - ], - onChanged: (value) { - setState(() { - _selectedCategory = value; - }); - }, - ), - const SizedBox(height: 16), - // Source Dropdown - DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Source'), - value: _selectedSource, - items: const [ - // Placeholder items - DropdownMenuItem(value: 'cnn', child: Text('CNN')), - DropdownMenuItem(value: 'reuters', child: Text('Reuters')), - ], - onChanged: (value) { - setState(() { - _selectedSource = value; - }); - }, - ), - const SizedBox(height: 16), - // Event Country Dropdown - DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Event Country'), - value: _selectedEventCountry, - items: const [ - // Placeholder items - DropdownMenuItem(value: 'US', child: Text('United States')), - DropdownMenuItem(value: 'UK', child: Text('United Kingdom')), - DropdownMenuItem(value: 'CA', child: Text('Canada')), - ], - onChanged: (value) { - setState(() { - _selectedEventCountry = value; - }); - }, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - widget.onApplyFilters( - _selectedCategory, - _selectedSource, - _selectedEventCountry, - ); - widget.bloc.add( - HeadlinesFeedFilterChanged( - category: _selectedCategory, - source: _selectedSource, - eventCountry: _selectedEventCountry, - ), - ); - Navigator.pop(context); - }, - child: const Text('Apply Filters'), - ), - ], - ), - ); - } -} diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index d292814e..ddcb74d0 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_headlines_repository/ht_headlines_repository.dart'; import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; -import 'package:ht_main/headlines-feed/view/headline_filter_bottom_sheet.dart'; import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; import 'package:ht_main/shared/widgets/failure_state_widget.dart'; import 'package:ht_main/shared/widgets/loading_state_widget.dart'; @@ -73,24 +72,12 @@ class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { IconButton( icon: const Icon(Icons.filter_list), onPressed: () { + final bloc = context.read(); showModalBottomSheet( context: context, builder: (BuildContext context) { - final bloc = context.read(); - return BlocProvider.value( - value: bloc, - child: HeadlineFilterBottomSheet( - bloc: bloc, - onApplyFilters: (category, source, eventCountry) { - bloc.add( - HeadlinesFeedFilterChanged( - category: category, - source: source, - eventCountry: eventCountry, - ), - ); - }, - ), + return _HeadlinesFilterBottomSheet( + bloc: bloc, ); }, ); @@ -139,3 +126,116 @@ class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { ); } } + +class _HeadlinesFilterBottomSheet extends StatefulWidget { + const _HeadlinesFilterBottomSheet({ + required this.bloc, + }); + + final HeadlinesFeedBloc bloc; + + @override + State<_HeadlinesFilterBottomSheet> createState() => + _HeadlinesFilterBottomSheetState(); +} + +class _HeadlinesFilterBottomSheetState + extends State<_HeadlinesFilterBottomSheet> { + String? selectedCategory; + String? selectedSource; + String? selectedEventCountry; + + @override + void initState() { + super.initState(); + final state = widget.bloc.state; + if (state is HeadlinesFeedLoaded) { + selectedCategory = state.filter.category; + selectedSource = state.filter.source; + selectedEventCountry = state.filter.eventCountry; + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.bloc, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Filter Headlines', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + // Category Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Category'), + value: selectedCategory, + items: const [ + // Placeholder items + DropdownMenuItem(value: 'technology', child: Text('Technology')), + DropdownMenuItem(value: 'business', child: Text('Business')), + DropdownMenuItem(value: 'Politics', child: Text('Sports')), + ], + onChanged: (value) { + setState(() { + selectedCategory = value; + }); + }, + ), + const SizedBox(height: 16), + // Source Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Source'), + value: selectedSource, + items: const [ + // Placeholder items + DropdownMenuItem(value: 'cnn', child: Text('CNN')), + DropdownMenuItem(value: 'reuters', child: Text('Reuters')), + ], + onChanged: (value) { + setState(() { + selectedSource = value; + }); + }, + ), + const SizedBox(height: 16), + // Event Country Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Event Country'), + value: selectedEventCountry, + items: const [ + // Placeholder items + DropdownMenuItem(value: 'US', child: Text('United States')), + DropdownMenuItem(value: 'UK', child: Text('United Kingdom')), + DropdownMenuItem(value: 'CA', child: Text('Canada')), + ], + onChanged: (value) { + setState(() { + selectedEventCountry = value; + }); + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + widget.bloc.add( + HeadlinesFeedFilterChanged( + category: selectedCategory, + source: selectedSource, + eventCountry: selectedEventCountry, + ), + ); + Navigator.pop(context); + }, + child: const Text('Apply Filters'), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 0f53a6fd..d7784807 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.9.5 +version: 0.11.5 publish_to: none repository: https://github.com/headlines-toolkit/ht-main environment: From 7030a9789d7c55c990452a5fa03937b0ca0ec9ab Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 16:16:56 +0100 Subject: [PATCH 09/11] feat(feed): add filter reset and scroll - Added reset filter button - Added null value to dropdowns - Added SingleChildScrollView --- .../view/headlines_feed_page.dart | 177 ++++++++++-------- 1 file changed, 104 insertions(+), 73 deletions(-) diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index ddcb74d0..fbb6d6a6 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -145,7 +145,7 @@ class _HeadlinesFilterBottomSheetState String? selectedSource; String? selectedEventCountry; - @override + @override void initState() { super.initState(); final state = widget.bloc.state; @@ -162,78 +162,109 @@ class _HeadlinesFilterBottomSheetState value: widget.bloc, child: Padding( padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Filter Headlines', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - // Category Dropdown - DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Category'), - value: selectedCategory, - items: const [ - // Placeholder items - DropdownMenuItem(value: 'technology', child: Text('Technology')), - DropdownMenuItem(value: 'business', child: Text('Business')), - DropdownMenuItem(value: 'Politics', child: Text('Sports')), - ], - onChanged: (value) { - setState(() { - selectedCategory = value; - }); - }, - ), - const SizedBox(height: 16), - // Source Dropdown - DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Source'), - value: selectedSource, - items: const [ - // Placeholder items - DropdownMenuItem(value: 'cnn', child: Text('CNN')), - DropdownMenuItem(value: 'reuters', child: Text('Reuters')), - ], - onChanged: (value) { - setState(() { - selectedSource = value; - }); - }, - ), - const SizedBox(height: 16), - // Event Country Dropdown - DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Event Country'), - value: selectedEventCountry, - items: const [ - // Placeholder items - DropdownMenuItem(value: 'US', child: Text('United States')), - DropdownMenuItem(value: 'UK', child: Text('United Kingdom')), - DropdownMenuItem(value: 'CA', child: Text('Canada')), - ], - onChanged: (value) { - setState(() { - selectedEventCountry = value; - }); - }, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - widget.bloc.add( - HeadlinesFeedFilterChanged( - category: selectedCategory, - source: selectedSource, - eventCountry: selectedEventCountry, - ), - ); - Navigator.pop(context); - }, - child: const Text('Apply Filters'), - ), - ], + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Text( + // 'Filter Headlines', + // style: Theme.of(context).textTheme.titleLarge, + // ), + const SizedBox(height: 16), + // Category Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Category'), + value: selectedCategory, + items: const [ + // Placeholder items + const DropdownMenuItem( + value: null, child: Text('All')), + DropdownMenuItem( + value: 'technology', child: Text('Technology')), + DropdownMenuItem(value: 'business', child: Text('Business')), + DropdownMenuItem(value: 'Politics', child: Text('Sports')), + ], + onChanged: (value) { + setState(() { + selectedCategory = value; + }); + }, + ), + const SizedBox(height: 16), + // Source Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Source'), + value: selectedSource, + items: const [ + // Placeholder items + const DropdownMenuItem( + value: null, child: Text('All')), + DropdownMenuItem(value: 'cnn', child: Text('CNN')), + DropdownMenuItem(value: 'reuters', child: Text('Reuters')), + ], + onChanged: (value) { + setState(() { + selectedSource = value; + }); + }, + ), + const SizedBox(height: 16), + // Event Country Dropdown + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Event Country'), + value: selectedEventCountry, + items: const [ + // Placeholder items + const DropdownMenuItem( + value: null, child: Text('All')), + DropdownMenuItem(value: 'US', child: Text('United States')), + DropdownMenuItem(value: 'UK', child: Text('United Kingdom')), + DropdownMenuItem(value: 'CA', child: Text('Canada')), + ], + onChanged: (value) { + setState(() { + selectedEventCountry = value; + }); + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + widget.bloc.add( + HeadlinesFeedFilterChanged( + category: selectedCategory, + source: selectedSource, + eventCountry: selectedEventCountry, + ), + ); + Navigator.pop(context); + }, + child: const Text('Apply Filters'), + ), + const SizedBox(height: 8), + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () { + setState(() { + selectedCategory = null; + selectedSource = null; + selectedEventCountry = null; + }); + widget.bloc.add( + const HeadlinesFeedFilterChanged( + category: null, + source: null, + eventCountry: null, + ), + ); + Navigator.pop(context); + }, + child: const Text('Reset Filters'), + ), + ], + ), ), ), ); From 9a9d90d9738e7cdc32361ab68044ce686be32ebe Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 16:22:08 +0100 Subject: [PATCH 10/11] feat(feed): show filter indicator on icon - Added visual filter indicator - Show when filter is applied --- .../view/headlines_feed_page.dart | 49 ++++++++++++++----- pubspec.yaml | 2 +- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index fbb6d6a6..635a0d30 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -69,17 +69,44 @@ class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { appBar: AppBar( title: const Text('Headlines Feed'), actions: [ - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: () { - final bloc = context.read(); - showModalBottomSheet( - context: context, - builder: (BuildContext context) { - return _HeadlinesFilterBottomSheet( - bloc: bloc, - ); - }, + BlocBuilder( + builder: (context, state) { + bool isFilterApplied = false; + if (state is HeadlinesFeedLoaded) { + isFilterApplied = state.filter.category != null || + state.filter.source != null || + state.filter.eventCountry != null; + } + return Stack( + children: [ + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () { + final bloc = context.read(); + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return _HeadlinesFilterBottomSheet( + bloc: bloc, + ); + }, + ); + }, + ), + if (isFilterApplied) + Positioned( + top: 8, + right: 8, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], ); }, ), diff --git a/pubspec.yaml b/pubspec.yaml index d7784807..d0cdff0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.11.5 +version: 0.13.0 publish_to: none repository: https://github.com/headlines-toolkit/ht-main environment: From ae924539acc90070397540eb9a469636b8876a66 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 14 Mar 2025 16:23:39 +0100 Subject: [PATCH 11/11] fix(feed): fix filter reset and type - Fixed filter reset logic - Changed bool to var - Removed unnecessary params --- .../view/headlines_feed_page.dart | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 635a0d30..e96d0b2e 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -71,7 +71,7 @@ class _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { actions: [ BlocBuilder( builder: (context, state) { - bool isFilterApplied = false; + var isFilterApplied = false; if (state is HeadlinesFeedLoaded) { isFilterApplied = state.filter.category != null || state.filter.source != null || @@ -204,10 +204,13 @@ class _HeadlinesFilterBottomSheetState value: selectedCategory, items: const [ // Placeholder items - const DropdownMenuItem( - value: null, child: Text('All')), + DropdownMenuItem( + child: Text('All'), + ), DropdownMenuItem( - value: 'technology', child: Text('Technology')), + value: 'technology', + child: Text('Technology'), + ), DropdownMenuItem(value: 'business', child: Text('Business')), DropdownMenuItem(value: 'Politics', child: Text('Sports')), ], @@ -224,8 +227,9 @@ class _HeadlinesFilterBottomSheetState value: selectedSource, items: const [ // Placeholder items - const DropdownMenuItem( - value: null, child: Text('All')), + DropdownMenuItem( + child: Text('All'), + ), DropdownMenuItem(value: 'cnn', child: Text('CNN')), DropdownMenuItem(value: 'reuters', child: Text('Reuters')), ], @@ -242,8 +246,9 @@ class _HeadlinesFilterBottomSheetState value: selectedEventCountry, items: const [ // Placeholder items - const DropdownMenuItem( - value: null, child: Text('All')), + DropdownMenuItem( + child: Text('All'), + ), DropdownMenuItem(value: 'US', child: Text('United States')), DropdownMenuItem(value: 'UK', child: Text('United Kingdom')), DropdownMenuItem(value: 'CA', child: Text('Canada')), @@ -280,11 +285,7 @@ class _HeadlinesFilterBottomSheetState selectedEventCountry = null; }); widget.bloc.add( - const HeadlinesFeedFilterChanged( - category: null, - source: null, - eventCountry: null, - ), + const HeadlinesFeedFilterChanged(), ); Navigator.pop(context); },