News Reader App with Flutter BLoC
Build a Flutter news reader app using Clean Architecture and BLoC pattern.
Introduction
30 Second Summary
Every news app on your phone fetches articles, handles loading states, and recovers gracefully when your connection drops. Behind the scenes, this requires code organized into layers that can change independently without breaking each other.
In this project, you will build a news reader app in Flutter using Clean Architecture and the BLoC pattern. You'll structure your code into three distinct layers and wire them together with dependency injection.
What You'll Build
A Flutter app that fetches top headlines from a news API and displays them in a scrollable list, structured with Domain, Data, and Presentation layers.
By the end of this project, you'll have:
- A Domain layer with pure Dart entities and use cases that contain your business logic.
- A Data layer that fetches articles from a remote API and maps JSON responses to typed models.
- A Presentation layer powered by BLoC that manages loading, success, and error states reactively.
- A dependency injection setup with get_it that wires all layers together without tight coupling.
- Secret Mission: Add pull-to-refresh and error handling UX with SnackBar notifications.
Are there any prerequisites?
This project assumes basic familiarity with Dart syntax and Flutter widgets like StatelessWidget, Scaffold, and ListView. You do not need prior experience with BLoC or Clean Architecture.
Set Up Flutter and Create the Project
Before you can build anything, you need the right tools on your machine.
In this step, you will install the Flutter SDK, create a fresh project, and add the packages that power Clean Architecture and BLoC state management.
In this step, get ready to:
- Install and verify the Flutter SDK.
- Create a new Flutter project called news_reader.
- Add five dependencies to your project.
Install and verify the Flutter SDK
Flutter bundles both the Dart language runtime and a full toolchain for building cross-platform apps. This project requires Dart SDK ^3.0.0, which ships with Flutter 3.10 and above.
- Check whether Flutter is already installed by running this command in your terminal:
flutter --version
✔️ I see Flutter 3.10 or higher
Your Flutter SDK is ready. Move on to the next substep.
ⓧ I see a version lower than 3.10
You have Flutter installed, but it is too old for this project. Upgrade to the latest stable release.
- Upgrade Flutter by running this command:
flutter upgrade
- After the upgrade completes, verify the new version by running:
flutter --version
You should see Flutter 3.10 or higher in the output.
ⓧ Command not found
Flutter is not installed on your machine yet. Follow the official installation guide for your operating system.
- Visit the Flutter install page: https://docs.flutter.dev/get-started/install.
- Follow the steps for your platform (macOS, Windows, or Linux).
- Make sure to add Flutter to your system PATH as described in the guide.
- After installation, restart your terminal and verify by running:
flutter --version
You should see a version number of 3.10 or higher.
- Run flutter doctor to check your environment is ready for development:
flutter doctor
What does flutter doctor do?
This command scans your machine for required tools (Android SDK, Xcode, Chrome, connected devices) and reports any issues. You do not need every check to pass for this project. As long as you see a green checkmark next to Flutter and at least one target platform, you are good to go.
Create the Flutter project
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Flutter project called news_reader by running:
flutter create news_reader
- Move into the new project folder by running:
cd news_reader
- Confirm the project files exist by running:
ls
You should see folders like lib, android, ios, and a file called pubspec.yaml in the output.
- Open the news_reader project in your editor. If you use VS Code, run:
code .
Using Android Studio instead?
Open Android Studio, click Open, and navigate to the news_reader folder on your Desktop.
flutter create failed?
Make sure you ran cd ~/Desktop first. The command creates a folder in whatever directory your terminal is currently in.
If you see a permissions error, try running the command without sudo. Flutter should not require elevated permissions.
Help me troubleshoot why flutter create is failing.
Add project dependencies
Your project needs five packages. Each one handles a specific architectural concern.
- In your editor, open pubspec.yaml (located at the root of the news_reader folder).
- Replace the entire contents of pubspec.yaml with the following:
name: news_reader
description: A news reader app built with Flutter Clean Architecture and BLoC.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^9.1.1
get_it: ^9.2.1
equatable: ^2.0.8
dartz: ^0.10.1
http: ^1.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
What do these packages do?
- flutter_bloc provides the BLoC classes (Bloc, BlocProvider, BlocBuilder) for state management.
- get_it is a service locator for dependency injection. It wires your layers together without tight coupling.
- equatable makes it easy to compare Dart objects by value instead of by reference.
- dartz provides the Either type for functional error handling. You return Right for success and Left for failure instead of throwing exceptions.
- http is a simple HTTP client for making API calls.
- Save pubspec.yaml.
- Install the dependencies by running this command in your terminal:
flutter pub get
You should see output ending with a message that all packages were resolved successfully.
Seeing dependency resolution errors?
Double-check that your pubspec.yaml indentation uses spaces, not tabs. YAML is sensitive to whitespace.
Make sure your Flutter SDK version is 3.10 or higher. Older versions may not resolve the Dart SDK ^3.0.0 constraint.
Help me fix my pubspec.yaml dependency errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved pubspec.yaml and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete pubspec.yaml you should have at this point:
name: news_reader
description: A news reader app built with Flutter Clean Architecture and BLoC.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^9.1.1
get_it: ^9.2.1
equatable: ^2.0.8
dartz: ^0.10.1
http: ^1.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Your project is scaffolded and ready to go. Next up, you will build the Domain layer with entities, a repository contract, and your first use case.
Build the Domain Layer
Your Flutter project is created and all dependencies are installed. Now it's time to build the architectural foundation that keeps your code maintainable as it grows.
In Clean Architecture, the Domain layer sits at the center of your app. It contains pure Dart code with no Flutter imports, no HTTP calls, and no UI logic. This is where your business rules live.
In this step, get ready to:
- Create the feature-based folder structure for the Domain layer.
- Define the Article entity as a pure Dart class.
- Write the repository contract and GetArticles use case.
Create the feature-based folder structure
Clean Architecture organizes code by feature, not by file type. Each feature gets its own folder with three sub-layers: domain, data, and presentation.
- Create the folder structure for the articles feature and the shared core directory by running these commands in your terminal:
macOS
mkdir -p lib/core/error lib/features/articles/domain/entities lib/features/articles/domain/repositories lib/features/articles/domain/usecases
Windows
mkdir -Force lib\core\error, lib\features\articles\domain\entities, lib\features\articles\domain\repositories, lib\features\articles\domain\usecases
- Confirm the folders were created by running:
ls lib/features/articles/domain/
You should see entities, repositories, and usecases listed in the output.
Why feature-based folders?
Grouping by feature (articles/) instead of by type (models/, screens/) keeps related code together. When you add a new feature later, you create one new folder instead of scattering files across many directories.
Before you build the domain classes, you need shared error types that all layers can reference. These live in lib/core/error/.
- Create a new file called failures.dart inside lib/core/error/ and add this code:
import 'package:equatable/equatable.dart';
// Base failure class that all specific failures extend
abstract class Failure extends Equatable {
const Failure();
@override
List<Object> get props => [];
}
// Returned when the API responds with a non-200 status
class ServerFailure extends Failure {
const ServerFailure();
}
// Returned when the device has no network connection
class NetworkFailure extends Failure {
const NetworkFailure();
}
What does this code do?
The Failure class is an abstract base that extends Equatable. This means two failures of the same type are considered equal, which makes testing straightforward.
ServerFailure and NetworkFailure are concrete types that your repository will return when something goes wrong. The BLoC layer uses these to decide what error message to show.
- Save failures.dart. You won't see a visible change yet. The next file (exceptions.dart) complements these failures.
- Create a new file called exceptions.dart inside lib/core/error/ and add this code:
// Thrown by data sources when the server returns an error
class ServerException implements Exception {
final String message;
const ServerException({this.message = 'Server error occurred'});
}
// Thrown when a network request fails entirely
class NetworkException implements Exception {
final String message;
const NetworkException({this.message = 'Network error occurred'});
}
Failures vs exceptions?
Exceptions are thrown by the Data layer when something goes wrong (a bad HTTP response, no internet). The repository catches these exceptions and converts them into Failure objects.
The Domain and Presentation layers never see raw exceptions. They only work with typed Failures wrapped in an Either type. This keeps error handling predictable.
- Save exceptions.dart.
- Verify both files compile by running:
flutter analyze
You should see No issues found! in the output.
Seeing analysis errors?
Make sure failures.dart has the import 'package:equatable/equatable.dart'; line at the top. Without it, Dart cannot find the Equatable class.
Check that you ran flutter pub get in the previous step. The equatable package must be downloaded before you can import it.
Help me fix my flutter analyze errors
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
const Failure();
@override
List<Object> get props => [];
}
class ServerFailure extends Failure {
const ServerFailure();
}
class NetworkFailure extends Failure {
const NetworkFailure();
}
class ServerException implements Exception {
final String message;
const ServerException({this.message = 'Server error occurred'});
}
class NetworkException implements Exception {
final String message;
const NetworkException({this.message = 'Network error occurred'});
}
Define the Article entity
An entity is a plain Dart object that represents a core concept in your app. The Article entity defines what a news article looks like to your business logic, independent of how the API returns the data.
- Create a new file called article.dart inside lib/features/articles/domain/entities/ and add this code:
import 'package:equatable/equatable.dart';
// Represents a news article in the domain layer
// No Flutter imports, no JSON parsing - just pure data
class Article extends Equatable {
final String title;
final String description;
final String url;
final String imageUrl;
const Article({
required this.title,
required this.description,
required this.url,
required this.imageUrl,
});
@override
List<Object> get props => [title, description, url, imageUrl];
}
What does this code do?
The Article class extends Equatable so that two Article objects with the same field values are considered equal. This is essential for BLoC state comparisons.
The props getter tells Equatable which fields to compare. Every field in the class is listed so equality checks are thorough.
- Save article.dart. You won't see a change yet. The next file (article_repository.dart) references this entity.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
import 'package:equatable/equatable.dart';
class Article extends Equatable {
final String title;
final String description;
final String url;
final String imageUrl;
const Article({
required this.title,
required this.description,
required this.url,
required this.imageUrl,
});
@override
List<Object> get props => [title, description, url, imageUrl];
}
Create the repository contract and use case
The Domain layer never knows how data is fetched. Instead, it defines a contract (an abstract class) that the Data layer must fulfill. A use case then calls that contract to perform one specific action.
What is a repository contract?
A repository contract is an abstract class that declares what operations are available (like "get articles") without specifying how they work. The Data layer provides the concrete implementation that actually makes HTTP requests.
This separation means you can swap the real API for a mock during testing without changing any Domain or Presentation code.
- Create a new file called article_repository.dart inside lib/features/articles/domain/repositories/ and add this code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/article.dart';
// Contract that the Data layer must implement
// Returns Either: Left(Failure) on error, Right(List<Article>) on success
abstract class ArticleRepository {
Future<Either<Failure, List<Article>>> getArticles();
}
What is Either?
Either comes from the dartz package. It represents a value that is one of two types: Left (conventionally the error) or Right (the success value).
So Either<Failure, List<Article>> means "this method returns either a Failure or a list of Articles." Your BLoC can then pattern-match on the result without try/catch blocks.
- Save article_repository.dart. You won't see a change yet. The next file (get_articles.dart) uses this contract.
- Create a new file called get_articles.dart inside lib/features/articles/domain/usecases/ and add this code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/article.dart';
import '../repositories/article_repository.dart';
// A use case represents one action the app can perform
// GetArticles delegates to the repository contract
class GetArticles {
final ArticleRepository repository;
GetArticles(this.repository);
Future<Either<Failure, List<Article>>> call() async {
return await repository.getArticles();
}
}
Why a use case for something so simple?
Right now, GetArticles just forwards the call to the repository. That feels like extra boilerplate.
In a real app, use cases grow. You might add caching logic, rate limiting, or data transformations here. Keeping them separate from the BLoC means your state management stays thin and focused on UI logic only.
- Save get_articles.dart.
- Verify all Domain layer files compile by running:
flutter analyze
You should see No issues found! in the output. This confirms all imports resolve correctly and the type signatures are valid.
Seeing import errors?
Check that your folder structure matches exactly: lib/features/articles/domain/ with subdirectories entities/, repositories/, and usecases/. A misnamed folder breaks the relative import paths.
Verify that article_repository.dart imports '../../../../core/error/failures.dart' with exactly four ../ segments. Each segment moves up one directory level.
Help me fix my import path errors
✔️ Awesome, I've got everything!
Great. Double check you've saved all files.
ⓧ I'd like to double check the full code
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/article.dart';
abstract class ArticleRepository {
Future<Either<Failure, List<Article>>> getArticles();
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/article.dart';
import '../repositories/article_repository.dart';
class GetArticles {
final ArticleRepository repository;
GetArticles(this.repository);
Future<Either<Failure, List<Article>>> call() async {
return await repository.getArticles();
}
}
Your Domain layer is complete. You have a pure Dart entity, a repository contract, and a use case with zero Flutter dependencies. Next up, you'll build the Data layer that actually fetches articles from a real API and maps them into these domain objects.
Build the Data Layer
Your Domain layer defines what your app needs (entities, contracts, use cases). Now it is time to build the layer that handles how those needs get fulfilled with real-world data.
The Data layer is responsible for fetching JSON from a remote API, parsing it into Dart objects, and catching any network or server errors before they leak into the rest of the app.
In this step, get ready to:
- Create the ArticleModel with a JSON parsing factory constructor.
- Implement the remote data source that fetches headlines from NewsAPI.org.
- Implement the repository that bridges the Data and Domain layers with error handling.
Create the data layer folders
The Data layer mirrors the Domain layer's structure but focuses on concrete implementations.
- Create the data layer subdirectories inside your lib/features/articles/ folder by running these commands:
mkdir -p lib/features/articles/data/models
mkdir -p lib/features/articles/data/datasources
mkdir -p lib/features/articles/data/repositories
- Confirm the folders exist by running:
ls lib/features/articles/data/
You should see datasources, models, and repositories listed in the output.
Create the ArticleModel class
The API returns JSON, but your Domain layer works with pure Dart entities. A model class bridges this gap by knowing how to parse JSON into an entity.
- Create a new file at lib/features/articles/data/models/article_model.dart and add the following code:
import '../../domain/entities/article.dart';
class ArticleModel extends Article {
const ArticleModel({
required super.title,
required super.description,
required super.url,
required super.imageUrl,
});
factory ArticleModel.fromJson(Map<String, dynamic> json) {
return ArticleModel(
title: json['title'] ?? 'No title',
description: json['description'] ?? 'No description',
url: json['url'] ?? '',
imageUrl: json['urlToImage'] ?? '',
);
}
}
What does this code do?
ArticleModel extends the Article entity from your Domain layer. It inherits all four fields (title, description, url, imageUrl) without redefining them.
The fromJson factory constructor takes a JSON map and creates an ArticleModel instance. The ?? operator provides fallback values for any missing keys, so the app never crashes on incomplete API data.
Notice json['urlToImage'] maps to imageUrl. This is where the model translates the API's naming conventions into your app's clean field names.
- Save article_model.dart.
Seeing an import error?
Make sure the relative import path ../../domain/entities/article.dart matches your folder structure. The data/models/ folder needs to go up two levels to reach domain/entities/.
help me fix my ArticleModel import path
Implement the remote data source
The data source is the class that actually talks to the network. It uses the http package to call the NewsAPI.org REST endpoint and returns a list of models.
What is NewsAPI.org?
NewsAPI.org is a free REST API that returns JSON search results for news articles from thousands of sources worldwide. The Developer plan gives you 100 requests per day at no cost.
You will need a free API key to make requests. The key gets appended to the URL as a query parameter.
- Go to https://newsapi.org/register in your browser.
- Enter your first name, email address, and a password.
- Select I am an individual.
- Agree to the terms and click Submit.
- Copy your API key from the confirmation page. Keep it somewhere safe for the next step.
Keep your API key private
Never commit API keys to version control. For this learning project you will paste the key directly into the source file. In a production app, you would store it in an environment variable or a secure secrets manager.
- Create a new file at lib/features/articles/data/datasources/articles_remote_data_source.dart and add the following code:
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../models/article_model.dart';
abstract class ArticlesRemoteDataSource {
Future<List<ArticleModel>> getArticles();
}
class ArticlesRemoteDataSourceImpl implements ArticlesRemoteDataSource {
final http.Client client;
ArticlesRemoteDataSourceImpl({required this.client});
@override
Future<List<ArticleModel>> getArticles() async {
final response = await client.get(
Uri.parse(
'https://newsapi.org/v2/top-headlines?country=us&apiKey=your-api-key-here',
),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonData = json.decode(response.body);
final List<dynamic> articlesJson = jsonData['articles'] ?? [];
return articlesJson
.map((article) => ArticleModel.fromJson(article))
.toList();
} else {
throw const ServerException(message: 'Failed to load articles');
}
}
}
- Replace your-api-key-here in the URL string with the API key you copied from NewsAPI.org.
What does this code do?
The abstract class ArticlesRemoteDataSource defines a contract (just like the repository contract in Domain). This lets you swap the real implementation for a mock during testing.
ArticlesRemoteDataSourceImpl uses an injected http.Client to make a GET request to the NewsAPI top headlines endpoint. If the response status is 200, it decodes the JSON body and maps each article object into an ArticleModel.
If the status code is anything other than 200, it throws a ServerException. This exception will be caught by the repository in the next substep.
- Save articles_remote_data_source.dart.
Seeing a red underline on the http import?
Make sure you ran flutter pub get after adding the http dependency to pubspec.yaml in the previous step. Run it again now if the import is unresolved.
help me resolve my http package import issue
Implement the repository
The repository implementation sits between the data source and the Domain layer. Its job is to catch raw exceptions and convert them into typed Failure objects wrapped in an Either type.
- Create a new file at lib/features/articles/data/repositories/article_repository_impl.dart and add the following code:
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/article.dart';
import '../../domain/repositories/article_repository.dart';
import '../datasources/articles_remote_data_source.dart';
class ArticleRepositoryImpl implements ArticleRepository {
final ArticlesRemoteDataSource remoteDataSource;
ArticleRepositoryImpl({required this.remoteDataSource});
@override
Future<Either<Failure, List<Article>>> getArticles() async {
try {
final remoteArticles = await remoteDataSource.getArticles();
return Right(remoteArticles);
} on ServerException {
return const Left(ServerFailure());
} catch (e) {
return const Left(NetworkFailure());
}
}
}
What does this code do?
ArticleRepositoryImpl fulfills the ArticleRepository contract from the Domain layer. It delegates the actual fetching to the data source.
The try/catch block catches ServerException specifically and wraps it in Left(ServerFailure()). Any other exception (network timeout, DNS failure) is caught by the generic catch and wrapped in Left(NetworkFailure()).
On success, the list of articles gets wrapped in Right(remoteArticles). This way, the Domain layer never sees raw exceptions. It only works with Either<Failure, List<Article>>.
- Save article_repository_impl.dart.
- Check that your editor shows no red underlines in any of the three new files.
Seeing unresolved import errors?
Double check that your folder structure matches the import paths. The data/repositories/ folder needs to go up four levels (../../../../) to reach core/error/. Verify the file names match exactly, including underscores.
help me fix my repository import paths
Why Either instead of try/catch everywhere?
Without Either, every caller of the repository would need its own try/catch. Errors become invisible unless you remember to handle them. Either makes error handling explicit in the return type.
The BLoC you build in the next step can simply call result.fold() to handle both cases cleanly. No exceptions escape into the UI layer.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in your data/ folder.
ⓧ I'd like to double check the full code
Here is each Data layer file in full for comparison.
import '../../domain/entities/article.dart';
class ArticleModel extends Article {
const ArticleModel({
required super.title,
required super.description,
required super.url,
required super.imageUrl,
});
factory ArticleModel.fromJson(Map<String, dynamic> json) {
return ArticleModel(
title: json['title'] ?? 'No title',
description: json['description'] ?? 'No description',
url: json['url'] ?? '',
imageUrl: json['urlToImage'] ?? '',
);
}
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../models/article_model.dart';
abstract class ArticlesRemoteDataSource {
Future<List<ArticleModel>> getArticles();
}
class ArticlesRemoteDataSourceImpl implements ArticlesRemoteDataSource {
final http.Client client;
ArticlesRemoteDataSourceImpl({required this.client});
@override
Future<List<ArticleModel>> getArticles() async {
final response = await client.get(
Uri.parse(
'https://newsapi.org/v2/top-headlines?country=us&apiKey=your-api-key-here',
),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonData = json.decode(response.body);
final List<dynamic> articlesJson = jsonData['articles'] ?? [];
return articlesJson
.map((article) => ArticleModel.fromJson(article))
.toList();
} else {
throw const ServerException(message: 'Failed to load articles');
}
}
}
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/article.dart';
import '../../domain/repositories/article_repository.dart';
import '../datasources/articles_remote_data_source.dart';
class ArticleRepositoryImpl implements ArticleRepository {
final ArticlesRemoteDataSource remoteDataSource;
ArticleRepositoryImpl({required this.remoteDataSource});
@override
Future<Either<Failure, List<Article>>> getArticles() async {
try {
final remoteArticles = await remoteDataSource.getArticles();
return Right(remoteArticles);
} on ServerException {
return const Left(ServerFailure());
} catch (e) {
return const Left(NetworkFailure());
}
}
}
Your Data layer is complete. You have a model that parses JSON, a data source that fetches from the API, and a repository that wraps everything in safe error handling. Next up, you will build the Presentation layer with BLoC to turn this data into a reactive UI.
Build the Presentation Layer with BLoC
Your Domain and Data layers are complete. They define what an article is, how to fetch articles from the API, and how to handle errors along the way.
Now you need a way to connect all of that logic to something the user actually sees. The Presentation layer is responsible for managing UI state and rendering widgets. You will use the BLoC pattern to keep this layer reactive and testable.
In this step, get ready to:
- Define BLoC events and states that model every possible UI scenario.
- Create an ArticlesBloc that calls the use case and emits states.
- Build the ArticlesPage widget that reacts to state changes with BlocBuilder.
Define the BLoC events and states
In the BLoC pattern, events represent what happens (the user wants articles) and states represent what the UI should show (loading, loaded, error). Defining these as explicit classes gives you compile-time safety over every possible screen.
- Create the presentation folder structure by running these commands in your terminal from the news_reader project root:
mkdir -p lib/features/articles/presentation/bloc
mkdir -p lib/features/articles/presentation/pages
- Confirm the folders exist by running:
ls lib/features/articles/presentation/
You should see bloc and pages listed in the output.
- Create a new file at lib/features/articles/presentation/bloc/articles_event.dart and add the following code:
import 'package:equatable/equatable.dart';
// Base class for all events the ArticlesBloc can receive
abstract class ArticlesEvent extends Equatable {
const ArticlesEvent();
@override
List<Object> get props => [];
}
// Dispatched when the UI wants to load articles
class GetArticlesEvent extends ArticlesEvent {}
What does this code do?
ArticlesEvent is the base class for anything the UI can tell the BLoC. Right now there is only one event: GetArticlesEvent, which means "go fetch articles."
Extending Equatable lets the BLoC compare events by value instead of by reference. This avoids duplicate processing of identical events.
- Save articles_event.dart.
- Now create a new file at lib/features/articles/presentation/bloc/articles_state.dart and add the following code:
import 'package:equatable/equatable.dart';
import '../../domain/entities/article.dart';
// Base class for all possible UI states
abstract class ArticlesState extends Equatable {
const ArticlesState();
@override
List<Object> get props => [];
}
// Initial state before any action is taken
class ArticlesInitial extends ArticlesState {}
// Loading spinner should be visible
class ArticlesLoading extends ArticlesState {}
// Articles fetched successfully
class ArticlesLoaded extends ArticlesState {
final List<Article> articles;
const ArticlesLoaded({required this.articles});
@override
List<Object> get props => [articles];
}
// Something went wrong
class ArticlesError extends ArticlesState {
final String message;
const ArticlesError({required this.message});
@override
List<Object> get props => [message];
}
What does this code do?
- ArticlesInitial is the starting state. The app has not tried to load anything yet.
- ArticlesLoading tells the UI to show a progress indicator.
- ArticlesLoaded carries the list of articles to display.
- ArticlesError carries an error message so the UI can show what went wrong.
Each state is its own class. The UI uses type checks to decide which widget tree to render for each scenario.
- Save articles_state.dart.
Seeing import errors?
Make sure the relative import path in articles_state.dart points to ../../domain/entities/article.dart. If your file is in the wrong folder, the relative path will not resolve.
help me fix import errors in my articles_state.dart file
Create the ArticlesBloc
The BLoC is the bridge between your events and states. It receives an event, runs the appropriate logic (calling the use case), and emits a new state for the UI to react to.
- Create a new file at lib/features/articles/presentation/bloc/articles_bloc.dart and add the following code:
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_articles.dart';
import 'articles_event.dart';
import 'articles_state.dart';
class ArticlesBloc extends Bloc<ArticlesEvent, ArticlesState> {
final GetArticles getArticles;
// Register event handlers in the constructor
ArticlesBloc({required this.getArticles}) : super(ArticlesInitial()) {
on<GetArticlesEvent>(_onGetArticles);
}
// Handle the GetArticlesEvent by calling the use case
Future<void> _onGetArticles(
GetArticlesEvent event,
Emitter<ArticlesState> emit,
) async {
emit(ArticlesLoading());
final result = await getArticles();
result.fold(
(failure) => emit(const ArticlesError(message: 'Failed to load articles')),
(articles) => emit(ArticlesLoaded(articles: articles)),
);
}
}
What does this code do?
- ArticlesBloc extends Bloc<ArticlesEvent, ArticlesState>, which means it takes in events and produces states.
- on<GetArticlesEvent>(_onGetArticles) registers a handler. Whenever the UI dispatches a GetArticlesEvent, the _onGetArticles method runs.
- result.fold() handles the Either returned by the use case. A Left (failure) emits ArticlesError. A Right (success) emits ArticlesLoaded.
Why BLoC instead of Cubit?
A Cubit exposes methods directly (like cubit.fetchArticles()). A BLoC uses explicit event objects instead.
Events give you a clear log of everything that happened. They also allow advanced patterns like debouncing and throttling directly on the event stream. Production apps favour BLoC because these event transformations scale better as the app grows.
- Save articles_bloc.dart.
You will not see a visual change yet. The BLoC needs to be wired into a widget before it can drive the UI.
Seeing a type mismatch error?
Make sure your getArticles field is typed as GetArticles (the use case class), not ArticleRepository. The BLoC depends on the use case, not the repository directly.
help me debug a type error in my ArticlesBloc
Build the ArticlesPage with BlocBuilder
BlocBuilder is a widget that rebuilds itself every time the BLoC emits a new state. You will use type checks to render a different widget tree for each state class you defined.
- Create a new file at lib/features/articles/presentation/pages/articles_page.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/articles_bloc.dart';
import '../bloc/articles_event.dart';
import '../bloc/articles_state.dart';
class ArticlesPage extends StatelessWidget {
const ArticlesPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('News Reader'),
centerTitle: true,
),
// BlocBuilder rebuilds the body whenever the state changes
body: BlocBuilder<ArticlesBloc, ArticlesState>(
builder: (context, state) {
if (state is ArticlesLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is ArticlesLoaded) {
return ListView.builder(
itemCount: state.articles.length,
itemBuilder: (context, index) {
final article = state.articles[index];
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: ListTile(
leading: article.imageUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
article.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.broken_image, size: 80),
),
)
: const Icon(Icons.article, size: 80),
title: Text(
article.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
article.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
},
);
} else if (state is ArticlesError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(state.message),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<ArticlesBloc>().add(GetArticlesEvent());
},
child: const Text('Retry'),
),
],
),
);
}
// Default state (ArticlesInitial)
return const Center(child: Text('Press to load articles'));
},
),
);
}
}
What does this code do?
- BlocBuilder<ArticlesBloc, ArticlesState> listens to the BLoC and rebuilds whenever a new state is emitted.
- For ArticlesLoading, a spinner is shown. For ArticlesLoaded, a scrollable list of cards renders each article with its image, title, and description.
- For ArticlesError, an error icon, message, and a Retry button are displayed. The Retry button dispatches GetArticlesEvent() to try again.
- context.read<ArticlesBloc>() accesses the BLoC without subscribing to state changes. This is the correct way to dispatch events from callbacks.
- Save articles_page.dart.
You will not be able to run the app yet. The page references ArticlesBloc but nothing provides it to the widget tree. That wiring happens in the next step with dependency injection.
Seeing undefined class errors?
Check that all four imports at the top of articles_page.dart match the file names exactly. The import for articles_bloc.dart, articles_event.dart, and articles_state.dart must use relative paths starting with ../bloc/ since the page is in a sibling folder.
help me fix undefined class errors in my ArticlesPage
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files in the presentation/ folder.
ⓧ I'd like to double check the full code
Here is each file in full for comparison.
import 'package:equatable/equatable.dart';
abstract class ArticlesEvent extends Equatable {
const ArticlesEvent();
@override
List<Object> get props => [];
}
class GetArticlesEvent extends ArticlesEvent {}
import 'package:equatable/equatable.dart';
import '../../domain/entities/article.dart';
abstract class ArticlesState extends Equatable {
const ArticlesState();
@override
List<Object> get props => [];
}
class ArticlesInitial extends ArticlesState {}
class ArticlesLoading extends ArticlesState {}
class ArticlesLoaded extends ArticlesState {
final List<Article> articles;
const ArticlesLoaded({required this.articles});
@override
List<Object> get props => [articles];
}
class ArticlesError extends ArticlesState {
final String message;
const ArticlesError({required this.message});
@override
List<Object> get props => [message];
}
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_articles.dart';
import 'articles_event.dart';
import 'articles_state.dart';
class ArticlesBloc extends Bloc<ArticlesEvent, ArticlesState> {
final GetArticles getArticles;
ArticlesBloc({required this.getArticles}) : super(ArticlesInitial()) {
on<GetArticlesEvent>(_onGetArticles);
}
Future<void> _onGetArticles(
GetArticlesEvent event,
Emitter<ArticlesState> emit,
) async {
emit(ArticlesLoading());
final result = await getArticles();
result.fold(
(failure) => emit(const ArticlesError(message: 'Failed to load articles')),
(articles) => emit(ArticlesLoaded(articles: articles)),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/articles_bloc.dart';
import '../bloc/articles_event.dart';
import '../bloc/articles_state.dart';
class ArticlesPage extends StatelessWidget {
const ArticlesPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('News Reader'),
centerTitle: true,
),
body: BlocBuilder<ArticlesBloc, ArticlesState>(
builder: (context, state) {
if (state is ArticlesLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is ArticlesLoaded) {
return ListView.builder(
itemCount: state.articles.length,
itemBuilder: (context, index) {
final article = state.articles[index];
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: ListTile(
leading: article.imageUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
article.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.broken_image, size: 80),
),
)
: const Icon(Icons.article, size: 80),
title: Text(
article.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
article.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
},
);
} else if (state is ArticlesError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(state.message),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<ArticlesBloc>().add(GetArticlesEvent());
},
child: const Text('Retry'),
),
],
),
);
}
return const Center(child: Text('Press to load articles'));
},
),
);
}
}
Your Presentation layer is ready. You have events, states, a BLoC, and a page that knows how to render every scenario. Next up, you will wire all three layers together with dependency injection so the app can actually run.
Wire Everything with Dependency Injection
You have all three layers built. The Domain layer defines what the app needs. The Data layer knows how to fetch it. The Presentation layer knows how to display it.
The problem is that none of these layers are connected yet. The BLoC needs a use case, the use case needs a repository, and the repository needs a data source. Dependency Injection solves this by creating a central place that wires everything together without any layer knowing about the others directly.
In this step, get ready to:
- Create a dependency injection container that registers all layers using get_it.
- Update main.dart to initialize the service locator and provide the BLoC to the widget tree.
- Run the app and verify articles load from the API through all three architectural layers.
Create the injection container
The injection container is a single file that acts as the app's wiring diagram. It tells get_it how to create each dependency and what each one needs.
- Create a new file called injection_container.dart inside the lib/ folder (at the same level as main.dart).
- Add the following code to injection_container.dart:
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'features/articles/data/datasources/articles_remote_data_source.dart';
import 'features/articles/data/repositories/article_repository_impl.dart';
import 'features/articles/domain/repositories/article_repository.dart';
import 'features/articles/domain/usecases/get_articles.dart';
import 'features/articles/presentation/bloc/articles_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
// BLoC
sl.registerFactory(
() => ArticlesBloc(getArticles: sl()),
);
// Use cases
sl.registerLazySingleton(() => GetArticles(sl()));
// Repositories
sl.registerLazySingleton<ArticleRepository>(
() => ArticleRepositoryImpl(remoteDataSource: sl()),
);
// Data sources
sl.registerLazySingleton<ArticlesRemoteDataSource>(
() => ArticlesRemoteDataSourceImpl(client: sl()),
);
// External
sl.registerLazySingleton(() => http.Client());
}
What does this code do?
This file creates a global sl variable (short for "service locator") that holds all the app's dependencies.
- registerFactory creates a NEW instance every time it's requested. The BLoC is registered this way because each screen needs its own fresh state.
- registerLazySingleton creates the instance only once, the first time it's needed. Use cases, repositories, and data sources are shared across the app.
- Each sl() call inside a registration resolves the required dependency automatically. get_it figures out the chain from bottom (http.Client) to top (ArticlesBloc).
Why register from top to bottom?
The registration order (BLoC first, then use case, then repository, then data source, then external) matches how you read the dependency graph. However, get_it resolves dependencies lazily, so the actual creation order is bottom-up at runtime.
- Save lib/injection_container.dart.
Seeing import errors?
Make sure injection_container.dart is directly inside lib/, not inside the features/ folder. The relative import paths depend on this file sitting at the lib/ root.
help me fix import errors in my injection_container.dart
Initialize the service locator in main.dart
The app's main.dart currently contains default Flutter boilerplate. You need to replace it so the app initializes the service locator before running, and provides the ArticlesBloc to the widget tree.
- Open lib/main.dart and replace its entire contents with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'features/articles/presentation/bloc/articles_bloc.dart';
import 'features/articles/presentation/pages/articles_page.dart';
import 'injection_container.dart' as di;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await di.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'News Reader',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: BlocProvider(
create: (_) => di.sl<ArticlesBloc>()..add(GetArticlesEvent()),
child: const ArticlesPage(),
),
);
}
}
What does this code do?
- WidgetsFlutterBinding.ensureInitialized() is required before any async work in main(). It prepares Flutter's engine before the service locator runs.
- await di.init() registers every dependency in the container before the app starts. This guarantees all classes are available when widgets request them.
- BlocProvider injects the ArticlesBloc into the widget tree. The ..add(GetArticlesEvent()) cascade immediately dispatches the first event, so articles begin loading as soon as the app starts.
- di.sl<ArticlesBloc>() asks get_it for an ArticlesBloc instance. Because the BLoC was registered as a factory, get_it creates a fresh one with its GetArticles dependency automatically resolved.
- Save lib/main.dart.
Seeing an error about GetArticlesEvent not found?
Make sure your import for articles_bloc.dart is correct. The GetArticlesEvent class is accessible through the bloc's event imports. If your editor still shows a red underline, try adding this import at the top of main.dart:
import 'features/articles/presentation/bloc/articles_event.dart';
help me fix GetArticlesEvent not found in main.dart
Run the app and verify the full architecture
Everything is wired. The service locator connects all three layers without any of them directly depending on each other.
- Make sure you have an emulator running or a physical device connected.
- Run the app by executing this command in your terminal:
flutter run
- Wait for the app to compile and launch on your device or emulator.
- You should see a list of news articles with headlines, descriptions, and thumbnail images loaded from the NewsAPI.
What just happened across all three layers?
- main.dart asked get_it for an ArticlesBloc, which triggered the factory registration.
- The BLoC dispatched GetArticlesEvent, which called the GetArticles use case in the Domain layer.
- The use case called ArticleRepository, which resolved to ArticleRepositoryImpl in the Data layer.
- The repository used ArticlesRemoteDataSourceImpl to hit the NewsAPI, parse JSON into models, and return Either<Failure, List<Article>>.
- The BLoC folded the result and emitted ArticlesLoaded, which BlocBuilder picked up to render the list.
Seeing an error state instead of articles?
If the app shows the error icon with a Retry button, the API call likely failed. Check these common causes:
- Make sure you replaced your-api-key-here in articles_remote_data_source.dart with your actual NewsAPI key from https://newsapi.org/register.
- Confirm your device or emulator has internet access.
- If you see a SocketException in the terminal, your emulator may not be connected to the network. Try restarting it.
help me debug why my news reader app shows an error instead of articles
✔️ Awesome, I've got everything!
Great. Double check you have saved both injection_container.dart and main.dart.
ⓧ I'd like to double check the full code
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'features/articles/data/datasources/articles_remote_data_source.dart';
import 'features/articles/data/repositories/article_repository_impl.dart';
import 'features/articles/domain/repositories/article_repository.dart';
import 'features/articles/domain/usecases/get_articles.dart';
import 'features/articles/presentation/bloc/articles_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
// BLoC
sl.registerFactory(
() => ArticlesBloc(getArticles: sl()),
);
// Use cases
sl.registerLazySingleton(() => GetArticles(sl()));
// Repositories
sl.registerLazySingleton<ArticleRepository>(
() => ArticleRepositoryImpl(remoteDataSource: sl()),
);
// Data sources
sl.registerLazySingleton<ArticlesRemoteDataSource>(
() => ArticlesRemoteDataSourceImpl(client: sl()),
);
// External
sl.registerLazySingleton(() => http.Client());
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'features/articles/presentation/bloc/articles_bloc.dart';
import 'features/articles/presentation/pages/articles_page.dart';
import 'injection_container.dart' as di;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await di.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'News Reader',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: BlocProvider(
create: (_) => di.sl<ArticlesBloc>()..add(GetArticlesEvent()),
child: const ArticlesPage(),
),
);
}
}
Secret mission
Add Pull-to-Refresh and Error Handling UX
Your app works, but what happens when the network drops? Production apps need graceful error recovery. Add pull-to-refresh to reload articles, a SnackBar that alerts users to failures, and a retry button that gets them back on track.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs. Your NewsAPI.org free tier key has a 100-request daily limit but costs nothing to maintain.
Resources you used:
- The news_reader Flutter project folder on your Desktop.
- A free NewsAPI.org API key (no ongoing cost).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the app with more features.
- Your news_reader project stays on your Desktop, ready to open and run anytime.
- Your NewsAPI.org API key remains active on the free tier with no charges.
- Flutter SDK and all installed packages remain on your machine for future projects.
Pause - I'll come back to this later
Shut down any running processes to free up memory. Your files stay intact for when you return.
- Stop the Flutter dev server if it is still running by pressing Ctrl+C in your terminal.
- Close Android Studio or VS Code to free up memory.
- Close any running emulator or disconnect your physical device.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop the Flutter dev server if it is still running by pressing Ctrl+C in your terminal.
- Delete the project folder on macOS/Linux by running:
rm -rf ~/Desktop/news_reader
- Delete the project folder on Windows by running:
rmdir /s /q %USERPROFILE%\Desktop\news_reader
- Confirm the folder is gone by checking your Desktop. The news_reader folder should no longer appear.
- Your NewsAPI.org API key is free and costs nothing to keep. If you still want to revoke it, log in to your NewsAPI.org dashboard and delete the key from your account page.
Nice Work!
Nice Work!
You just built a production-style Flutter app using Clean Architecture and BLoC. This is the same architectural pattern used in large-scale Flutter apps where testability and maintainability matter.
You've learned how to:
- Built a three-layer architecture (Domain, Data, Presentation) with strict dependency rules that keep your code modular and testable.
- Used flutter_bloc to manage state with explicit events and states, giving your UI a predictable, reactive data flow.
- Set up dependency injection with get_it to wire all layers together without tight coupling.
- Secret Mission: Added pull-to-refresh, SnackBar error notifications, and a retry button for graceful error handling UX.
Ready to quiz yourself?