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/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 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 new file mode 100644 index 00000000..95154d44 --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -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 { + /// {@macro headlines_feed_bloc} + HeadlinesFeedBloc({required HtHeadlinesRepository headlinesRepository}) + : _headlinesRepository = headlinesRepository, + super(HeadlinesFeedLoading()) { + on( + _onHeadlinesFeedFetchRequested, + transformer: sequential(), + ); + on( + _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 + /// [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, + 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 _onHeadlinesFeedRefreshRequested( + HeadlinesFeedRefreshRequested event, + Emitter 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')); + } + } +} 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..ab09590e --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_feed_event.dart @@ -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 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 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 new file mode 100644 index 00000000..fae510eb --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_feed_state.dart @@ -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 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 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? 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, 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 get props => [message]; +} 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/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart new file mode 100644 index 00000000..e96d0b2e --- /dev/null +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -0,0 +1,300 @@ +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/loading_state_widget.dart'; + +class HeadlinesFeedPage extends StatelessWidget { + const HeadlinesFeedPage({super.key}); + + @override + 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 _HeadlinesFeedViewState extends State<_HeadlinesFeedView> { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + context.read().add(HeadlinesFeedRefreshRequested()); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) { + final state = context.read().state; + if (state is HeadlinesFeedLoaded) { + context + .read() + .add(const 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 Scaffold( + appBar: AppBar( + title: const Text('Headlines Feed'), + actions: [ + BlocBuilder( + builder: (context, state) { + var 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, + ), + ), + ), + ], + ); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + switch (state) { + 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()); + }, + ); + } + }, + ), + ); + } +} + +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: 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 + DropdownMenuItem( + 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 + DropdownMenuItem( + 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 + DropdownMenuItem( + 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(), + ); + Navigator.pop(context); + }, + child: const Text('Reset Filters'), + ), + ], + ), + ), + ), + ); + } +} 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..a0a250b6 --- /dev/null +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -0,0 +1,59 @@ +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: 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, + ), + 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/headlines/view/headlines_page.dart b/lib/headlines/view/headlines_page.dart deleted file mode 100644 index c0b10f2f..00000000 --- a/lib/headlines/view/headlines_page.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class HeadlinesPage extends StatelessWidget { - const HeadlinesPage({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); // Placeholder for now - } -} 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/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart new file mode 100644 index 00000000..4dd54dcc --- /dev/null +++ b/lib/shared/widgets/failure_state_widget.dart @@ -0,0 +1,47 @@ +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.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 9e32c01f..d0cdff0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,14 @@ name: ht_main description: main headlines toolkit mobile app. -version: 0.4.5 +version: 0.13.0 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: