In this tutorial, we’re going to build a Weather app in Flutter which demonstrates how to manage multiple cubits to implement dynamic theming, pull-to-refresh, and much more. Our weather app will pull live weather data from the public OpenMeteo API and demonstrate how to separate our application into layers (data, repository, business logic, and presentation).
Our app should let users
Search for a city on a dedicated search page
See a pleasant depiction of the weather data returned by Open Meteo API
Change the units displayed (metric vs imperial)
Additionally,
The theme of the application should reflect the weather for the chosen city
Application state should persist across sessions: i.e., the app should remember its state after closing and reopening it (using HydratedBloc )
Observe state changes with BlocObserver .
BlocProvider , Flutter widget which provides a bloc to its children.
BlocBuilder , Flutter widget that handles building the widget in response to new states.
Prevent unnecessary rebuilds with Equatable .
RepositoryProvider , a Flutter widget which provides a repository to its children.
BlocListener , a Flutter widget which invokes the listener code in response to state changes in the bloc.
MultiBlocProvider , a Flutter widget that merges multiple BlocProvider widgets into one
BlocConsumer , a Flutter widget that exposes a builder and listener in order to react to new states
HydratedBloc to manage and persist state
To begin, create a new flutter project
flutter create flutter_weather
Our app will consist of isolated features in corresponding directories. This enables us to scale as the number of features increases and allows developers to work on different features in parallel.
Our app can be broken down into four main features: search, settings, theme, weather . Let’s create those directories.
Following the bloc architecture guidelines, our application will consist of several layers.
In this tutorial, here’s what these layers will do:
Data : retrieve raw weather data from the API
Repository : abstract the data layer and expose domain models for the application to consume
Business Logic : manage the state of each feature (unit information, city details, themes, etc.)
Presentation : display weather information and collect input from users (settings page, search page etc.)
For this application we’ll be hitting the Open Meteo API .
We’ll be focusing on two endpoints:
https://geocoding-api.open-meteo.com/v1/search?name=$city&count=1
to get a location for a given city name
https://api.open-meteo.com/v1/forecast?latitude=$latitude&longitude=$longitude¤t_weather=true
to get the weather for a given location
Open https://geocoding-api.open-meteo.com/v1/search?name=chicago&count=1 in your browser to see the response for the city of Chicago. We will use the latitude
and longitude
in the response to hit the weather endpoint.
The latitude
/longitutde
for Chicago is 41.85003
/-87.65005
. Navigate to https://api.open-meteo.com/v1/forecast?latitude=43.0389&longitude=-87.90647¤t_weather=true in your browser and you’ll see the response for weather in Chicago which contains all the data we will need for our app.
The OpenMeteo API Client is independent of our application. As a result, we will create it as an internal package (and could even publish it on pub.dev ). We can then use the package by adding it to the pubspec.yaml
for the repository layer, which will handle data requests for our main weather application.
Create a new directory on the project level called packages
. This directory will store all of our internal packages.
Within this directory, run the built-in flutter create
command to create a new package called open_meteo_api
for our API client.
flutter create --template=package open_meteo_api
Next, let’s create location.dart
and weather.dart
which will contain the models for the location
and weather
API endpoint responses.
The location.dart
model should store data returned by the location API, which looks like the following:
Here’s the in-progress location.dart
file which stores the above response:
Next, let’s work on weather.dart
. Our weather model should store data returned by the weather API, which looks like the following:
Here’s the in-progress weather.dart
file which stores the above response:
const Weather ({ required this .temperature, required this .weatherCode});
final double temperature;
final double weatherCode;
While we’re here, let’s quickly create a barrel file to clean up some of our imports down the road.
Create a models.dart
barrel file and export the two models:
Let’s also create a package level barrel file, open_meteo_api.dart
In the top level, open_meteo_api.dart
let’s export the models:
export 'src/models/models.dart' ;
We need to be able to serialize and deserialize our models in order to work with the API data. To do this, we will add toJson
and fromJson
methods to our models.
Additionally, we need a way to make HTTP network requests to fetch data from an API. Fortunately, there are a number of popular packages for doing just that.
We will be using the json_annotation , json_serializable , and build_runner packages to generate the toJson
and fromJson
implementations for us.
In a later step, we will also use the http package to send network requests to the weather API so our application can display the current weather data.
Let’s add these dependencies to the pubspec.yaml
.
description : A Dart API Client for the Open-Meteo API.
json_serializable : ^6.3.1
In order for code generation to work, we need to annotate our code using the following:
@JsonSerializable
to label classes which can be serialized
@JsonKey
to provide string representations of field names
@JsonValue
to provide string representations of field values
Implement JSONConverter
to convert object representations into JSON representations
For each file we also need to:
Import json_annotation
Include the generated code using the part keyword
Include fromJson
methods for deserialization
Here is our complete location.dart
model file:
import 'package:json_annotation/json_annotation.dart' ;
factory Location . fromJson ( Map < String , dynamic > json) =>
_$LocationFromJson (json);
Here is our complete weather.dart
model file:
import 'package:json_annotation/json_annotation.dart' ;
const Weather ({ required this .temperature, required this .weatherCode});
factory Weather . fromJson ( Map < String , dynamic > json) =>
final double temperature;
@JsonKey (name : 'weathercode' )
final double weatherCode;
In the open_meteo_api
folder, create a build.yaml
file. The purpose of this file is to handle discrepancies between naming conventions in the json_serializable
field names.
source_gen|combining_builder :
- implicit_dynamic_parameter
Let’s use build_runner
to generate the code.
dart run build_runner build
build_runner
should generate the location.g.dart
and weather.g.dart
files.
Let’s create our API client in open_meteo_api_client.dart
within the src
directory. Our project structure should now look like this:
|-- open_meteo_api_client.dart
We can now use the http package we added earlier to the pubspec.yaml
file to make HTTP requests to the weather API and use this information in our application.
Our API client will expose two methods:
locationSearch
which returns a Future<Location>
getWeather
which returns a Future<Weather>
The locationSearch
method hits the location API and throws LocationRequestFailure
errors as applicable. The completed method looks as follows:
/// Finds a [Location] `/v1/search/?name=(query)` .
Future < Location > locationSearch ( String query) async {
final locationRequest = Uri . https (
{ 'name' : query, 'count' : '1' },
final locationResponse = await _httpClient. get (locationRequest);
if (locationResponse.statusCode != 200 ) {
throw LocationRequestFailure ();
final locationJson = jsonDecode (locationResponse.body) as Map ;
if ( ! locationJson. containsKey ( 'results' )) throw LocationNotFoundFailure ();
final results = locationJson[ 'results' ] as List ;
if (results.isEmpty) throw LocationNotFoundFailure ();
return Location . fromJson (results.first as Map < String , dynamic >);
Similarly, the getWeather
method hits the weather API and throws WeatherRequestFailure
errors as applicable. The completed method looks as follows:
/// Fetches [Weather] for a given [latitude] and [longitude] .
Future < Weather > getWeather ({
required double latitude,
required double longitude,
final weatherRequest = Uri . https (_baseUrlWeather, 'v1/forecast' , {
'longitude' : ' $ longitude ' ,
'current_weather' : 'true'
final weatherResponse = await _httpClient. get (weatherRequest);
if (weatherResponse.statusCode != 200 ) {
throw WeatherRequestFailure ();
final bodyJson = jsonDecode (weatherResponse.body) as Map < String , dynamic >;
if ( ! bodyJson. containsKey ( 'current_weather' )) {
throw WeatherNotFoundFailure ();
final weatherJson = bodyJson[ 'current_weather' ] as Map < String , dynamic >;
return Weather . fromJson (weatherJson);
The completed file looks like this:
import 'package:http/http.dart' as http;
import 'package:open_meteo_api/open_meteo_api.dart' ;
/// Exception thrown when locationSearch fails.
class LocationRequestFailure implements Exception {}
/// Exception thrown when the provided location is not found.
class LocationNotFoundFailure implements Exception {}
/// Exception thrown when getWeather fails.
class WeatherRequestFailure implements Exception {}
/// Exception thrown when weather for provided location is not found.
class WeatherNotFoundFailure implements Exception {}
/// {@template open_meteo_api_client}
/// Dart API Client which wraps the [Open Meteo API] (https://open-meteo.com).
class OpenMeteoApiClient {
/// {@macro open_meteo_api_client}
OpenMeteoApiClient ({http. Client ? httpClient})
: _httpClient = httpClient ?? http. Client ();
static const _baseUrlWeather = 'api.open-meteo.com' ;
static const _baseUrlGeocoding = 'geocoding-api.open-meteo.com' ;
final http. Client _httpClient;
/// Finds a [Location] `/v1/search/?name=(query)` .
Future < Location > locationSearch ( String query) async {
final locationRequest = Uri . https (
{ 'name' : query, 'count' : '1' },
final locationResponse = await _httpClient. get (locationRequest);
if (locationResponse.statusCode != 200 ) {
throw LocationRequestFailure ();
final locationJson = jsonDecode (locationResponse.body) as Map ;
if ( ! locationJson. containsKey ( 'results' )) throw LocationNotFoundFailure ();
final results = locationJson[ 'results' ] as List ;
if (results.isEmpty) throw LocationNotFoundFailure ();
return Location . fromJson (results.first as Map < String , dynamic >);
/// Fetches [Weather] for a given [latitude] and [longitude] .
Future < Weather > getWeather ({
required double latitude,
required double longitude,
final weatherRequest = Uri . https (_baseUrlWeather, 'v1/forecast' , {
'longitude' : ' $ longitude ' ,
'current_weather' : 'true' ,
final weatherResponse = await _httpClient. get (weatherRequest);
if (weatherResponse.statusCode != 200 ) {
throw WeatherRequestFailure ();
final bodyJson = jsonDecode (weatherResponse.body) as Map < String , dynamic >;
if ( ! bodyJson. containsKey ( 'current_weather' )) {
throw WeatherNotFoundFailure ();
final weatherJson = bodyJson[ 'current_weather' ] as Map < String , dynamic >;
return Weather . fromJson (weatherJson);
Let’s wrap up this package by adding our API client to the barrel file.
export 'src/models/models.dart' ;
export 'src/open_meteo_api_client.dart' ;
It’s especially important to write unit tests for the data layer since it’s the foundation of our application. Unit tests will give us confidence that the package behaves as expected.
Earlier, we added the test package to our pubspec.yaml which allows to easily write unit tests.
We will be creating a test file for the api client as well as the two models.
import 'package:open_meteo_api/open_meteo_api.dart' ;
import 'package:test/test.dart' ;
test ( 'returns correct Location object' , () {
. having ((w) => w.id, 'id' , 4887398 )
. having ((w) => w.name, 'name' , 'Chicago' )
. having ((w) => w.latitude, 'latitude' , 41.85003 )
. having ((w) => w.longitude, 'longitude' , - 87.65005 ),
import 'package:open_meteo_api/open_meteo_api.dart' ;
import 'package:test/test.dart' ;
test ( 'returns correct Weather object' , () {
< String , dynamic > { 'temperature' : 15.3 , 'weathercode' : 63 },
. having ((w) => w.temperature, 'temperature' , 15.3 )
. having ((w) => w.weatherCode, 'weatherCode' , 63 ),
Next, let’s test our API client. We should test to ensure that our API client handles both API calls correctly, including edge cases.
Note
We don’t want our tests to make real API calls since our goal is to test the API client logic (including all edge cases) and not the API itself. In order to have a consistent, controlled test environment, we will use mocktail (which we added to the pubspec.yaml file earlier) to mock the http
client.
// ignore_for_file: prefer_const_constructors
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart' ;
import 'package:open_meteo_api/open_meteo_api.dart' ;
import 'package:test/test.dart' ;
class MockHttpClient extends Mock implements http. Client {}
class MockResponse extends Mock implements http. Response {}
class FakeUri extends Fake implements Uri {}
group ( 'OpenMeteoApiClient' , () {
late http. Client httpClient;
late OpenMeteoApiClient apiClient;
registerFallbackValue ( FakeUri ());
httpClient = MockHttpClient ();
apiClient = OpenMeteoApiClient (httpClient : httpClient);
group ( 'constructor' , () {
test ( 'does not require an httpClient' , () {
expect ( OpenMeteoApiClient (), isNotNull);
group ( 'locationSearch' , () {
const query = 'mock-query' ;
test ( 'makes correct http request' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn ( '{}' );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
await apiClient. locationSearch (query);
'geocoding-api.open-meteo.com' ,
{ 'name' : query, 'count' : '1' },
test ( 'throws LocationRequestFailure on non-200 response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 400 );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
() async => apiClient. locationSearch (query),
throwsA ( isA < LocationRequestFailure >()),
test ( 'throws LocationNotFoundFailure on error response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn ( '{}' );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
apiClient. locationSearch (query),
throwsA ( isA < LocationNotFoundFailure >()),
test ( 'throws LocationNotFoundFailure on empty response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn ( '{"results": []}' );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
apiClient. locationSearch (query),
throwsA ( isA < LocationNotFoundFailure >()),
test ( 'returns Location on valid response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn (
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
final actual = await apiClient. locationSearch (query);
. having ((l) => l.name, 'name' , 'Chicago' )
. having ((l) => l.id, 'id' , 4887398 )
. having ((l) => l.latitude, 'latitude' , 41.85003 )
. having ((l) => l.longitude, 'longitude' , - 87.65005 ),
const latitude = 41.85003 ;
const longitude = - 87.6500 ;
test ( 'makes correct http request' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn ( '{}' );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
await apiClient. getWeather (latitude : latitude, longitude : longitude);
Uri . https ( 'api.open-meteo.com' , 'v1/forecast' , {
'longitude' : ' $ longitude ' ,
'current_weather' : 'true' ,
test ( 'throws WeatherRequestFailure on non-200 response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 400 );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
() async => apiClient. getWeather (
throwsA ( isA < WeatherRequestFailure >()),
test ( 'throws WeatherNotFoundFailure on empty response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn ( '{}' );
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
() async => apiClient. getWeather (
throwsA ( isA < WeatherNotFoundFailure >()),
test ( 'returns weather on valid response' , () async {
final response = MockResponse ();
when (() => response.statusCode). thenReturn ( 200 );
when (() => response.body). thenReturn (
"generationtime_ms": 0.2510547637939453,
"timezone_abbreviation": "GMT",
"time": "2022-09-12T01:00"
when (() => httpClient. get ( any ())). thenAnswer ((_) async => response);
final actual = await apiClient. getWeather (
. having ((w) => w.temperature, 'temperature' , 15.3 )
. having ((w) => w.weatherCode, 'weatherCode' , 63.0 ),
Finally, let’s gather test coverage to verify that we’ve covered each line of code with at least one test case.
genhtml coverage/lcov.info -o coverage
The goal of our repository layer is to abstract our data layer and facilitate communication with the bloc layer. In doing this, the rest of our code base depends only on functions exposed by our repository layer instead of specific data provider implementations. This allows us to change data providers without disrupting any of the application-level code. For example, if we decide to migrate away from this particular weather API, we should be able to create a new API client and swap it out without having to make changes to the public API of the repository or application layers.
Inside the packages directory, run the following command:
flutter create --template=package weather_repository
We will use the same packages as in the open_meteo_api
package including the open_meteo_api
package from the last step. Update your pubspec.yaml
and run flutter packages get
.
description : A Dart Repository which manages the weather domain.
json_serializable : ^6.3.1
Weather Repository Models
We will be creating a new weather.dart
file to expose a domain-specific weather model. This model will contain only data relevant to our business cases — in other words it should be completely decoupled from the API client and raw data format. As usual, we will also create a models.dart
barrel file.
This time, our weather model will only store the location, temperature, condition
properties. We will also continue to annotate our code to allow for serialization and deserialization.
import 'package:equatable/equatable.dart' ;
import 'package:json_annotation/json_annotation.dart' ;
class Weather extends Equatable {
required this .temperature,
factory Weather . fromJson ( Map < String , dynamic > json) =>
Map < String , dynamic > toJson () => _$WeatherToJson ( this );
final double temperature;
final WeatherCondition condition;
List < Object > get props => [location, temperature, condition];
Update the barrel file we created previously to include the models.
As before, we need to create a build.yaml
file with the following contents:
As we have done previously, run the following command to generate the (de)serialization implementation.
dart run build_runner build
Let’s also create a package-level barrel file named packages/weather_repository/lib/weather_repository.dart
to export our models:
library weather_repository;
export 'src/models/models.dart' ;
The main goal of the WeatherRepository
is to provide an interface which abstracts the data provider. In this case, the WeatherRepository
will have a dependency on the WeatherApiClient
and expose a single public method, getWeather(String city)
.
Note
Consumers of the WeatherRepository
are not privy to the underlying implementation details such as the fact that two network requests are made to the weather API. The goal of the WeatherRepository
is to separate the “what” from the “how” — in other words, we want to have a way to fetch weather for a given city, but don’t care about how or where that data is coming from.
Let’s create the weather_repository.dart
file within the src
directory of our package and work on the repository implementation.
The main method we will focus on is getWeather(String city)
. We can implement it using two calls to the API client as follows:
import 'package:open_meteo_api/open_meteo_api.dart' hide Weather;
import 'package:weather_repository/weather_repository.dart' ;
class WeatherRepository {
WeatherRepository ({ OpenMeteoApiClient ? weatherApiClient})
: _weatherApiClient = weatherApiClient ?? OpenMeteoApiClient ();
final OpenMeteoApiClient _weatherApiClient;
Future < Weather > getWeather ( String city) async {
final location = await _weatherApiClient. locationSearch (city);
final weather = await _weatherApiClient. getWeather (
latitude : location.latitude,
longitude : location.longitude,
temperature : weather.temperature,
condition : weather.weatherCode. toInt ().toCondition,
WeatherCondition get toCondition {
return WeatherCondition .clear;
return WeatherCondition .cloudy;
return WeatherCondition .rainy;
return WeatherCondition .snowy;
return WeatherCondition .unknown;
Update the barrel file we created previously.
export 'src/models/models.dart' ;
export 'src/weather_repository.dart' ;
Just as with the data layer, it’s critical to test the repository layer in order to make sure the domain level logic is correct. To test our WeatherRepository
, we will use the mocktail library. We will mock the underlying api client in order to unit test the WeatherRepository
logic in an isolated, controlled environment.
// ignore_for_file: prefer_const_constructors
import 'package:mocktail/mocktail.dart' ;
import 'package:open_meteo_api/open_meteo_api.dart' as open_meteo_api;
import 'package:test/test.dart' ;
import 'package:weather_repository/weather_repository.dart' ;
class MockOpenMeteoApiClient extends Mock
implements open_meteo_api. OpenMeteoApiClient {}
class MockLocation extends Mock implements open_meteo_api. Location {}
class MockWeather extends Mock implements open_meteo_api. Weather {}
group ( 'WeatherRepository' , () {
late open_meteo_api. OpenMeteoApiClient weatherApiClient;
late WeatherRepository weatherRepository;
weatherApiClient = MockOpenMeteoApiClient ();
weatherRepository = WeatherRepository (
weatherApiClient : weatherApiClient,
group ( 'constructor' , () {
test ( 'instantiates internal weather api client when not injected' , () {
expect ( WeatherRepository (), isNotNull);
const latitude = 41.85003 ;
const longitude = - 87.65005 ;
test ( 'calls locationSearch with correct city' , () async {
await weatherRepository. getWeather (city);
verify (() => weatherApiClient. locationSearch (city)). called ( 1 );
test ( 'throws when locationSearch fails' , () async {
final exception = Exception ( 'oops' );
when (() => weatherApiClient. locationSearch ( any ())). thenThrow (exception);
() async => weatherRepository. getWeather (city),
test ( 'calls getWeather with correct latitude/longitude' , () async {
final location = MockLocation ();
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
await weatherRepository. getWeather (city);
() => weatherApiClient. getWeather (
test ( 'throws when getWeather fails' , () async {
final exception = Exception ( 'oops' );
final location = MockLocation ();
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
() => weatherApiClient. getWeather (
latitude : any (named : 'latitude' ),
longitude : any (named : 'longitude' ),
() async => weatherRepository. getWeather (city),
test ( 'returns correct weather on success (clear)' , () async {
final location = MockLocation ();
final weather = MockWeather ();
when (() => location.name). thenReturn (city);
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weather.temperature). thenReturn ( 42.42 );
when (() => weather.weatherCode). thenReturn ( 0 );
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
() => weatherApiClient. getWeather (
latitude : any (named : 'latitude' ),
longitude : any (named : 'longitude' ),
). thenAnswer ((_) async => weather);
final actual = await weatherRepository. getWeather (city);
condition : WeatherCondition .clear,
test ( 'returns correct weather on success (cloudy)' , () async {
final location = MockLocation ();
final weather = MockWeather ();
when (() => location.name). thenReturn (city);
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weather.temperature). thenReturn ( 42.42 );
when (() => weather.weatherCode). thenReturn ( 1 );
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
() => weatherApiClient. getWeather (
latitude : any (named : 'latitude' ),
longitude : any (named : 'longitude' ),
). thenAnswer ((_) async => weather);
final actual = await weatherRepository. getWeather (city);
condition : WeatherCondition .cloudy,
test ( 'returns correct weather on success (rainy)' , () async {
final location = MockLocation ();
final weather = MockWeather ();
when (() => location.name). thenReturn (city);
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weather.temperature). thenReturn ( 42.42 );
when (() => weather.weatherCode). thenReturn ( 51 );
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
() => weatherApiClient. getWeather (
latitude : any (named : 'latitude' ),
longitude : any (named : 'longitude' ),
). thenAnswer ((_) async => weather);
final actual = await weatherRepository. getWeather (city);
condition : WeatherCondition .rainy,
test ( 'returns correct weather on success (snowy)' , () async {
final location = MockLocation ();
final weather = MockWeather ();
when (() => location.name). thenReturn (city);
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weather.temperature). thenReturn ( 42.42 );
when (() => weather.weatherCode). thenReturn ( 71 );
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
() => weatherApiClient. getWeather (
latitude : any (named : 'latitude' ),
longitude : any (named : 'longitude' ),
). thenAnswer ((_) async => weather);
final actual = await weatherRepository. getWeather (city);
condition : WeatherCondition .snowy,
test ( 'returns correct weather on success (unknown)' , () async {
final location = MockLocation ();
final weather = MockWeather ();
when (() => location.name). thenReturn (city);
when (() => location.latitude). thenReturn (latitude);
when (() => location.longitude). thenReturn (longitude);
when (() => weather.temperature). thenReturn ( 42.42 );
when (() => weather.weatherCode). thenReturn ( - 1 );
when (() => weatherApiClient. locationSearch ( any ())). thenAnswer (
() => weatherApiClient. getWeather (
latitude : any (named : 'latitude' ),
longitude : any (named : 'longitude' ),
). thenAnswer ((_) async => weather);
final actual = await weatherRepository. getWeather (city);
condition : WeatherCondition .unknown,
In the business logic layer, we will be consuming the weather domain model from the WeatherRepository
and exposing a feature-level model which will be surfaced to the user via the UI.
Note
This is the third different type of weather model we’re implementing. In the API client, our weather model contained all the info returned by the API. In the repository layer, our weather model contained only the abstracted model based on our business case. In this layer, our weather model will contain relevant information needed specifically for the current feature set.
Because our business logic layer resides in our main app, we need to edit the pubspec.yaml
for the entire flutter_weather
project and include all the packages we’ll be using.
Using equatable enables our app’s state class instances to be compared using the equals ==
operator. Under the hood, bloc will compare our states to see if they’re equal, and if they’re not, it will trigger a rebuild. This guarantees that our widget tree will only rebuild when necessary to keep performance fast and responsive.
We can spice up our user interface with google_fonts .
HydratedBloc allows us to persist application state when the app is closed and reopened.
We’ll include the weather_repository
package we just created to allow us to fetch the current weather data!
For testing, we’ll want to include the usual test
package, along with mocktail
for mocking dependencies and bloc_test , to enable easy testing of business logic units, or blocs!
description : A new Flutter project.
path : packages/weather_repository
json_serializable : ^6.0.0
uses-material-design : true
Next, we will be working on the application layer within the weather
feature directory.
The goal of our weather model is to keep track of weather data displayed by our app, as well as temperature settings (Celsius or Fahrenheit).
Create flutter_weather/lib/weather/models/weather.dart
:
import 'package:equatable/equatable.dart' ;
import 'package:json_annotation/json_annotation.dart' ;
import 'package:weather_repository/weather_repository.dart' hide Weather;
import 'package:weather_repository/weather_repository.dart'
enum TemperatureUnits { fahrenheit, celsius }
extension TemperatureUnitsX on TemperatureUnits {
bool get isFahrenheit => this == TemperatureUnits .fahrenheit;
bool get isCelsius => this == TemperatureUnits .celsius;
class Temperature extends Equatable {
const Temperature ({ required this .value});
factory Temperature . fromJson ( Map < String , dynamic > json) =>
_$TemperatureFromJson (json);
Map < String , dynamic > toJson () => _$TemperatureToJson ( this );
List < Object > get props => [value];
class Weather extends Equatable {
required this .lastUpdated,
required this .temperature,
factory Weather . fromJson ( Map < String , dynamic > json) =>
factory Weather . fromRepository (weather_repository. Weather weather) {
condition : weather.condition,
lastUpdated : DateTime . now (),
location : weather.location,
temperature : Temperature (value : weather.temperature),
static final empty = Weather (
condition : WeatherCondition .unknown,
lastUpdated : DateTime ( 0 ),
temperature : const Temperature (value : 0 ),
final WeatherCondition condition;
final DateTime lastUpdated;
final Temperature temperature;
List < Object > get props => [condition, lastUpdated, location, temperature];
Map < String , dynamic > toJson () => _$WeatherToJson ( this );
WeatherCondition ? condition,
Temperature ? temperature,
condition : condition ?? this .condition,
lastUpdated : lastUpdated ?? this .lastUpdated,
location : location ?? this .location,
temperature : temperature ?? this .temperature,
Create a build.yaml
file for the business logic layer.
Run build_runner
to generate the (de)serialization implementations.
dart run build_runner build
Let’s export our models from the barrel file (flutter_weather/lib/weather/models/models.dart
):
We will use HydratedCubit
to enable our app to remember its application state, even after it’s been closed and reopened.
Using the Bloc VSCode or Bloc IntelliJ extension, right click on the weather
directory and create a new cubit called Weather
. The project structure should look like this:
There are four states our weather app can be in:
initial
before anything loads
loading
during the API call
success
if the API call is successful
failure
if the API call is unsuccessful
The WeatherStatus
enum will represent the above.
The complete weather state should look like this:
part of 'weather_cubit.dart' ;
enum WeatherStatus { initial, loading, success, failure }
extension WeatherStatusX on WeatherStatus {
bool get isInitial => this == WeatherStatus .initial;
bool get isLoading => this == WeatherStatus .loading;
bool get isSuccess => this == WeatherStatus .success;
bool get isFailure => this == WeatherStatus .failure;
final class WeatherState extends Equatable {
this .status = WeatherStatus .initial,
this .temperatureUnits = TemperatureUnits .celsius,
}) : weather = weather ?? Weather .empty;
factory WeatherState . fromJson ( Map < String , dynamic > json) =>
_$WeatherStateFromJson (json);
final WeatherStatus status;
final TemperatureUnits temperatureUnits;
TemperatureUnits ? temperatureUnits,
status : status ?? this .status,
temperatureUnits : temperatureUnits ?? this .temperatureUnits,
weather : weather ?? this .weather,
Map < String , dynamic > toJson () => _$WeatherStateToJson ( this );
List < Object ?> get props => [status, temperatureUnits, weather];
Now that we’ve defined the WeatherState
, let’s write the WeatherCubit
which will expose the following methods:
fetchWeather(String? city)
uses our weather repository to try and retrieve a weather object for the given city
refreshWeather()
retrieves a new weather object using the weather repository given the current weather state
toggleUnits()
toggles the state between Celsius and Fahrenheit
fromJson(Map<String, dynamic> json)
, toJson(WeatherState state)
used for persistence
import 'package:equatable/equatable.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
import 'package:hydrated_bloc/hydrated_bloc.dart' ;
import 'package:json_annotation/json_annotation.dart' ;
import 'package:weather_repository/weather_repository.dart'
part 'weather_cubit.g.dart' ;
part 'weather_state.dart' ;
class WeatherCubit extends HydratedCubit < WeatherState > {
WeatherCubit ( this ._weatherRepository) : super ( WeatherState ());
final WeatherRepository _weatherRepository;
Future < void > fetchWeather ( String ? city) async {
if (city == null || city.isEmpty) return ;
emit (state. copyWith (status : WeatherStatus .loading));
final weather = Weather . fromRepository (
await _weatherRepository. getWeather (city),
final units = state.temperatureUnits;
final value = units.isFahrenheit
? weather.temperature.value. toFahrenheit ()
: weather.temperature.value;
status : WeatherStatus .success,
weather : weather. copyWith (temperature : Temperature (value : value)),
emit (state. copyWith (status : WeatherStatus .failure));
Future < void > refreshWeather () async {
if ( ! state.status.isSuccess) return ;
if (state.weather == Weather .empty) return ;
final weather = Weather . fromRepository (
await _weatherRepository. getWeather (state.weather.location),
final units = state.temperatureUnits;
final value = units.isFahrenheit
? weather.temperature.value. toFahrenheit ()
: weather.temperature.value;
status : WeatherStatus .success,
weather : weather. copyWith (temperature : Temperature (value : value)),
final units = state.temperatureUnits.isFahrenheit
? TemperatureUnits .celsius
: TemperatureUnits .fahrenheit;
if ( ! state.status.isSuccess) {
emit (state. copyWith (temperatureUnits : units));
final weather = state.weather;
if (weather != Weather .empty) {
final temperature = weather.temperature;
final value = units.isCelsius
? temperature.value. toCelsius ()
: temperature.value. toFahrenheit ();
weather : weather. copyWith (temperature : Temperature (value : value)),
WeatherState fromJson ( Map < String , dynamic > json) =>
WeatherState . fromJson (json);
Map < String , dynamic > toJson ( WeatherState state) => state. toJson ();
double toFahrenheit () => ( this * 9 / 5 ) + 32 ;
double toCelsius () => ( this - 32 ) * 5 / 9 ;
Similar to the data and repository layers, it’s critical to unit test the business logic layer to ensure that the feature-level logic behaves as we expect. We will be relying on the bloc_test in addition to mocktail
and test
.
Let’s add the test
, bloc_test
, and mocktail
packages to the dev_dependencies
.
description : A new Flutter project.
path : packages/weather_repository
json_serializable : ^6.0.0
uses-material-design : true
// ignore_for_file: prefer_const_constructors
import 'package:bloc_test/bloc_test.dart' ;
import 'package:flutter_test/flutter_test.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
import 'package:mocktail/mocktail.dart' ;
import 'package:weather_repository/weather_repository.dart'
import '../../helpers/hydrated_bloc.dart' ;
const weatherLocation = 'London' ;
const weatherCondition = weather_repository. WeatherCondition .rainy;
const weatherTemperature = 9.8 ;
class MockWeatherRepository extends Mock
implements weather_repository. WeatherRepository {}
class MockWeather extends Mock implements weather_repository. Weather {}
group ( 'WeatherCubit' , () {
late weather_repository. Weather weather;
late weather_repository. WeatherRepository weatherRepository;
late WeatherCubit weatherCubit;
weatherRepository = MockWeatherRepository ();
when (() => weather.condition). thenReturn (weatherCondition);
when (() => weather.location). thenReturn (weatherLocation);
when (() => weather.temperature). thenReturn (weatherTemperature);
() => weatherRepository. getWeather ( any ()),
). thenAnswer ((_) async => weather);
weatherCubit = WeatherCubit (weatherRepository);
test ( 'initial state is correct' , () {
final weatherCubit = WeatherCubit (weatherRepository);
expect (weatherCubit.state, WeatherState ());
group ( 'toJson/fromJson' , () {
test ( 'work properly' , () {
final weatherCubit = WeatherCubit (weatherRepository);
weatherCubit. fromJson (weatherCubit. toJson (weatherCubit.state)),
group ( 'fetchWeather' , () {
blocTest < WeatherCubit , WeatherState >(
'emits nothing when city is null' ,
build : () => weatherCubit,
act : (cubit) => cubit. fetchWeather ( null ),
expect : () => < WeatherState > [],
blocTest < WeatherCubit , WeatherState >(
'emits nothing when city is empty' ,
build : () => weatherCubit,
act : (cubit) => cubit. fetchWeather ( '' ),
expect : () => < WeatherState > [],
blocTest < WeatherCubit , WeatherState >(
'calls getWeather with correct city' ,
build : () => weatherCubit,
act : (cubit) => cubit. fetchWeather (weatherLocation),
verify (() => weatherRepository. getWeather (weatherLocation)). called ( 1 );
blocTest < WeatherCubit , WeatherState >(
'emits [loading, failure] when getWeather throws' ,
() => weatherRepository. getWeather ( any ()),
). thenThrow ( Exception ( 'oops' ));
build : () => weatherCubit,
act : (cubit) => cubit. fetchWeather (weatherLocation),
expect : () => < WeatherState > [
WeatherState (status : WeatherStatus .loading),
WeatherState (status : WeatherStatus .failure),
blocTest < WeatherCubit , WeatherState >(
'emits [loading, success] when getWeather returns (celsius)' ,
build : () => weatherCubit,
act : (cubit) => cubit. fetchWeather (weatherLocation),
WeatherState (status : WeatherStatus .loading),
. having ((w) => w.status, 'status' , WeatherStatus .success)
. having ((w) => w.lastUpdated, 'lastUpdated' , isNotNull)
. having ((w) => w.condition, 'condition' , weatherCondition)
Temperature (value : weatherTemperature),
. having ((w) => w.location, 'location' , weatherLocation),
blocTest < WeatherCubit , WeatherState >(
'emits [loading, success] when getWeather returns (fahrenheit)' ,
build : () => weatherCubit,
seed : () => WeatherState (temperatureUnits : TemperatureUnits .fahrenheit),
act : (cubit) => cubit. fetchWeather (weatherLocation),
status : WeatherStatus .loading,
temperatureUnits : TemperatureUnits .fahrenheit,
. having ((w) => w.status, 'status' , WeatherStatus .success)
. having ((w) => w.lastUpdated, 'lastUpdated' , isNotNull)
. having ((w) => w.condition, 'condition' , weatherCondition)
Temperature (value : weatherTemperature. toFahrenheit ()),
. having ((w) => w.location, 'location' , weatherLocation),
group ( 'refreshWeather' , () {
blocTest < WeatherCubit , WeatherState >(
'emits nothing when status is not success' ,
build : () => weatherCubit,
act : (cubit) => cubit. refreshWeather (),
expect : () => < WeatherState > [],
verifyNever (() => weatherRepository. getWeather ( any ()));
blocTest < WeatherCubit , WeatherState >(
'emits nothing when location is null' ,
build : () => weatherCubit,
seed : () => WeatherState (status : WeatherStatus .success),
act : (cubit) => cubit. refreshWeather (),
expect : () => < WeatherState > [],
verifyNever (() => weatherRepository. getWeather ( any ()));
blocTest < WeatherCubit , WeatherState >(
'invokes getWeather with correct location' ,
build : () => weatherCubit,
seed : () => WeatherState (
status : WeatherStatus .success,
location : weatherLocation,
temperature : Temperature (value : weatherTemperature),
lastUpdated : DateTime ( 2020 ),
condition : weatherCondition,
act : (cubit) => cubit. refreshWeather (),
verify (() => weatherRepository. getWeather (weatherLocation)). called ( 1 );
blocTest < WeatherCubit , WeatherState >(
'emits nothing when exception is thrown' ,
() => weatherRepository. getWeather ( any ()),
). thenThrow ( Exception ( 'oops' ));
build : () => weatherCubit,
seed : () => WeatherState (
status : WeatherStatus .success,
location : weatherLocation,
temperature : Temperature (value : weatherTemperature),
lastUpdated : DateTime ( 2020 ),
condition : weatherCondition,
act : (cubit) => cubit. refreshWeather (),
expect : () => < WeatherState > [],
blocTest < WeatherCubit , WeatherState >(
'emits updated weather (celsius)' ,
build : () => weatherCubit,
seed : () => WeatherState (
status : WeatherStatus .success,
location : weatherLocation,
temperature : Temperature (value : 0 ),
lastUpdated : DateTime ( 2020 ),
condition : weatherCondition,
act : (cubit) => cubit. refreshWeather (),
. having ((w) => w.status, 'status' , WeatherStatus .success)
. having ((w) => w.lastUpdated, 'lastUpdated' , isNotNull)
. having ((w) => w.condition, 'condition' , weatherCondition)
Temperature (value : weatherTemperature),
. having ((w) => w.location, 'location' , weatherLocation),
blocTest < WeatherCubit , WeatherState >(
'emits updated weather (fahrenheit)' ,
build : () => weatherCubit,
seed : () => WeatherState (
temperatureUnits : TemperatureUnits .fahrenheit,
status : WeatherStatus .success,
location : weatherLocation,
temperature : Temperature (value : 0 ),
lastUpdated : DateTime ( 2020 ),
condition : weatherCondition,
act : (cubit) => cubit. refreshWeather (),
. having ((w) => w.status, 'status' , WeatherStatus .success)
. having ((w) => w.lastUpdated, 'lastUpdated' , isNotNull)
. having ((w) => w.condition, 'condition' , weatherCondition)
Temperature (value : weatherTemperature. toFahrenheit ()),
. having ((w) => w.location, 'location' , weatherLocation),
group ( 'toggleUnits' , () {
blocTest < WeatherCubit , WeatherState >(
'emits updated units when status is not success' ,
build : () => weatherCubit,
act : (cubit) => cubit. toggleUnits (),
expect : () => < WeatherState > [
WeatherState (temperatureUnits : TemperatureUnits .fahrenheit),
blocTest < WeatherCubit , WeatherState >(
'emits updated units and temperature '
'when status is success (celsius)' ,
build : () => weatherCubit,
seed : () => WeatherState (
status : WeatherStatus .success,
temperatureUnits : TemperatureUnits .fahrenheit,
location : weatherLocation,
temperature : Temperature (value : weatherTemperature),
lastUpdated : DateTime ( 2020 ),
condition : WeatherCondition .rainy,
act : (cubit) => cubit. toggleUnits (),
expect : () => < WeatherState > [
status : WeatherStatus .success,
location : weatherLocation,
temperature : Temperature (value : weatherTemperature. toCelsius ()),
lastUpdated : DateTime ( 2020 ),
condition : WeatherCondition .rainy,
blocTest < WeatherCubit , WeatherState >(
'emits updated units and temperature '
'when status is success (fahrenheit)' ,
build : () => weatherCubit,
seed : () => WeatherState (
status : WeatherStatus .success,
location : weatherLocation,
temperature : Temperature (value : weatherTemperature),
lastUpdated : DateTime ( 2020 ),
condition : WeatherCondition .rainy,
act : (cubit) => cubit. toggleUnits (),
expect : () => < WeatherState > [
status : WeatherStatus .success,
temperatureUnits : TemperatureUnits .fahrenheit,
location : weatherLocation,
temperature : Temperature (
value : weatherTemperature. toFahrenheit (),
lastUpdated : DateTime ( 2020 ),
condition : WeatherCondition .rainy,
double toFahrenheit () => ( this * 9 / 5 ) + 32 ;
double toCelsius () => ( this - 32 ) * 5 / 9 ;
We will start with the WeatherPage
which uses BlocProvider
in order to provide an instance of the WeatherCubit
to the widget tree.
import 'package:flutter/material.dart' ;
import 'package:flutter_bloc/flutter_bloc.dart' ;
import 'package:flutter_weather/search/search.dart' ;
import 'package:flutter_weather/settings/settings.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
class WeatherPage extends StatelessWidget {
const WeatherPage ({ super .key});
Widget build ( BuildContext context) {
extendBodyBehindAppBar : true ,
icon : const Icon ( Icons .settings),
onPressed : () => Navigator . of (context). push < void >(
child : BlocBuilder < WeatherCubit , WeatherState >(
builder : (context, state) {
return switch (state.status) {
WeatherStatus .initial => const WeatherEmpty (),
WeatherStatus .loading => const WeatherLoading (),
WeatherStatus .failure => const WeatherError (),
WeatherStatus .success => WeatherPopulated (
units : state.temperatureUnits,
return context. read < WeatherCubit >(). refreshWeather ();
floatingActionButton : FloatingActionButton (
child : const Icon ( Icons .search, semanticLabel : 'Search' ),
final city = await Navigator . of (context). push ( SearchPage . route ());
if ( ! context.mounted) return ;
await context. read < WeatherCubit >(). fetchWeather (city);
You’ll notice that page depends on SettingsPage
and SearchPage
widgets, which we will create next.
The settings page allows users to update their preferences for the temperature units.
import 'package:flutter/material.dart' ;
import 'package:flutter_bloc/flutter_bloc.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
class SettingsPage extends StatelessWidget {
static Route < void > route () {
return MaterialPageRoute < void >(
builder : (_) => const SettingsPage ._(),
Widget build ( BuildContext context) {
appBar : AppBar (title : const Text ( 'Settings' )),
BlocBuilder < WeatherCubit , WeatherState >(
buildWhen : (previous, current) =>
previous.temperatureUnits != current.temperatureUnits,
builder : (context, state) {
title : const Text ( 'Temperature Units' ),
'Use metric measurements for temperature units.' ,
value : state.temperatureUnits.isCelsius,
onChanged : (_) => context. read < WeatherCubit >(). toggleUnits (),
The search page allows users to enter the name of their desired city and provides the search result to the previous route via Navigator.of(context).pop
.
import 'package:flutter/material.dart' ;
class SearchPage extends StatefulWidget {
static Route < String > route () {
return MaterialPageRoute (builder : (_) => const SearchPage ._());
State < SearchPage > createState () => _SearchPageState ();
class _SearchPageState extends State < SearchPage > {
final TextEditingController _textController = TextEditingController ();
String get _text => _textController.text;
_textController. dispose ();
Widget build ( BuildContext context) {
appBar : AppBar (title : const Text ( 'City Search' )),
padding : const EdgeInsets . all ( 8 ),
controller : _textController,
decoration : const InputDecoration (
key : const Key ( 'searchPage_search_iconButton' ),
icon : const Icon ( Icons .search, semanticLabel : 'Submit' ),
onPressed : () => Navigator . of (context). pop (_text),
The app will display different screens depending on the four possible states of the WeatherCubit
.
This screen will show when there is no data to display because the user has not yet selected a city.
import 'package:flutter/material.dart' ;
class WeatherEmpty extends StatelessWidget {
const WeatherEmpty ({ super .key});
Widget build ( BuildContext context) {
final theme = Theme . of (context);
mainAxisSize : MainAxisSize .min,
const Text ( '🏙️' , style : TextStyle (fontSize : 64 )),
style : theme.textTheme.headlineSmall,
This screen will display if there is an error.
import 'package:flutter/material.dart' ;
class WeatherError extends StatelessWidget {
const WeatherError ({ super .key});
Widget build ( BuildContext context) {
final theme = Theme . of (context);
mainAxisSize : MainAxisSize .min,
const Text ( '🙈' , style : TextStyle (fontSize : 64 )),
style : theme.textTheme.headlineSmall,
This screen will display as the application fetches the data.
import 'package:flutter/material.dart' ;
class WeatherLoading extends StatelessWidget {
const WeatherLoading ({ super .key});
Widget build ( BuildContext context) {
final theme = Theme . of (context);
mainAxisSize : MainAxisSize .min,
const Text ( '⛅' , style : TextStyle (fontSize : 64 )),
style : theme.textTheme.headlineSmall,
padding : EdgeInsets . all ( 16 ),
child : CircularProgressIndicator (),
This screen will display after the user has selected a city and we have retrieved the data.
import 'package:flutter/material.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
class WeatherPopulated extends StatelessWidget {
final TemperatureUnits units;
final ValueGetter < Future < void >> onRefresh;
Widget build ( BuildContext context) {
final theme = Theme . of (context);
alignment : const Alignment ( 0 , - 1 / 3 ),
child : SingleChildScrollView (
physics : const AlwaysScrollableScrollPhysics (),
mainAxisSize : MainAxisSize .min,
const SizedBox (height : 48 ),
_WeatherIcon (condition : weather.condition),
style : theme.textTheme.displayMedium ? . copyWith (
fontWeight : FontWeight .w200,
weather. formattedTemperature (units),
style : theme.textTheme.displaySmall ? . copyWith (
fontWeight : FontWeight .bold,
'''Last Updated at ${ TimeOfDay . fromDateTime ( weather . lastUpdated ). format ( context )} ''' ,
class _WeatherIcon extends StatelessWidget {
const _WeatherIcon ({ required this .condition});
static const _iconSize = 75.0 ;
final WeatherCondition condition;
Widget build ( BuildContext context) {
style : const TextStyle (fontSize : _iconSize),
extension on WeatherCondition {
case WeatherCondition .clear :
case WeatherCondition .rainy :
case WeatherCondition .cloudy :
case WeatherCondition .snowy :
case WeatherCondition .unknown :
class _WeatherBackground extends StatelessWidget {
Widget build ( BuildContext context) {
final color = Theme . of (context).colorScheme.primaryContainer;
decoration : BoxDecoration (
gradient : LinearGradient (
begin : Alignment .topCenter,
end : Alignment .bottomCenter,
stops : const [ 0.25 , 0.75 , 0.90 , 1.0 ],
Color brighten ([ int percent = 10 ]) {
1 <= percent && percent <= 100 ,
'percentage must be between 1 and 100' ,
red + (( 255 - red) * p). round (),
green + (( 255 - green) * p). round (),
blue + (( 255 - blue) * p). round (),
String formattedTemperature ( TemperatureUnits units) {
return ''' ${ temperature . value . toStringAsPrecision ( 2 )} ° ${ units . isCelsius ? 'C' : 'F' } ''' ;
Let’s add these states to a barrel file to clean up our imports.
export 'weather_empty.dart' ;
export 'weather_error.dart' ;
export 'weather_loading.dart' ;
export 'weather_populated.dart' ;
Our main.dart
file should initialize our WeatherApp
and BlocObserver
(for debugging purposes), as well as setup our HydratedStorage
to persist state across sessions.
import 'package:flutter/foundation.dart' ;
import 'package:flutter/material.dart' ;
import 'package:flutter_weather/app.dart' ;
import 'package:flutter_weather/weather_bloc_observer.dart' ;
import 'package:hydrated_bloc/hydrated_bloc.dart' ;
import 'package:path_provider/path_provider.dart' ;
import 'package:weather_repository/weather_repository.dart' ;
WidgetsFlutterBinding . ensureInitialized ();
Bloc .observer = const WeatherBlocObserver ();
HydratedBloc .storage = await HydratedStorage . build (
? HydratedStorage .webStorageDirectory
: await getTemporaryDirectory (),
runApp ( WeatherApp (weatherRepository : WeatherRepository ()));
Our app.dart
widget will handle building the WeatherPage
view we previously created and use BlocProvider
to inject our WeatherCubit
.
import 'package:flutter/material.dart' ;
import 'package:flutter_bloc/flutter_bloc.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
import 'package:google_fonts/google_fonts.dart' ;
import 'package:weather_repository/weather_repository.dart'
class WeatherApp extends StatelessWidget {
const WeatherApp ({ required WeatherRepository weatherRepository, super .key})
: _weatherRepository = weatherRepository;
final WeatherRepository _weatherRepository;
Widget build ( BuildContext context) {
create : (_) => WeatherCubit (_weatherRepository),
child : const WeatherAppView (),
class WeatherAppView extends StatelessWidget {
const WeatherAppView ({ super .key});
Widget build ( BuildContext context) {
final seedColor = context. select (
( WeatherCubit cubit) => cubit.state.weather.toColor,
appBarTheme : const AppBarTheme (
backgroundColor : Colors .transparent,
colorScheme : ColorScheme . fromSeed (seedColor : seedColor),
textTheme : GoogleFonts . rajdhaniTextTheme (),
home : const WeatherPage (),
case WeatherCondition .clear :
case WeatherCondition .snowy :
return Colors .lightBlueAccent;
case WeatherCondition .cloudy :
case WeatherCondition .rainy :
return Colors .indigoAccent;
case WeatherCondition .unknown :
The bloc_test
library also exposes MockBlocs
and MockCubits
which make it easy to test UI. We can mock the states of the various cubits and ensure that the UI reacts correctly.
// ignore_for_file: prefer_const_constructors
import 'package:bloc_test/bloc_test.dart' ;
import 'package:flutter/material.dart' ;
import 'package:flutter_bloc/flutter_bloc.dart' ;
import 'package:flutter_test/flutter_test.dart' ;
import 'package:flutter_weather/search/search.dart' ;
import 'package:flutter_weather/settings/settings.dart' ;
import 'package:flutter_weather/weather/weather.dart' ;
import 'package:mocktail/mocktail.dart' ;
import 'package:weather_repository/weather_repository.dart' hide Weather;
import '../../helpers/hydrated_bloc.dart' ;
class MockWeatherRepository extends Mock implements WeatherRepository {}
class MockWeatherCubit extends MockCubit < WeatherState >
implements WeatherCubit {}
group ( 'WeatherPage' , () {
temperature : Temperature (value : 4.2 ),
condition : WeatherCondition .cloudy,
lastUpdated : DateTime ( 2020 ),
late WeatherCubit weatherCubit;
weatherCubit = MockWeatherCubit ();
testWidgets ( 'renders WeatherEmpty for WeatherStatus.initial' ,
when (() => weatherCubit.state). thenReturn ( WeatherState ());
child : MaterialApp (home : WeatherPage ()),
expect (find. byType ( WeatherEmpty ), findsOneWidget);
testWidgets ( 'renders WeatherLoading for WeatherStatus.loading' ,
when (() => weatherCubit.state). thenReturn (
status : WeatherStatus .loading,
child : MaterialApp (home : WeatherPage ()),
expect (find. byType ( WeatherLoading ), findsOneWidget);
testWidgets ( 'renders WeatherPopulated for WeatherStatus.success' ,
when (() => weatherCubit.state). thenReturn (
status : WeatherStatus .success,
child : MaterialApp (home : WeatherPage ()),
expect (find. byType ( WeatherPopulated ), findsOneWidget);
testWidgets ( 'renders WeatherError for WeatherStatus.failure' ,
when (() => weatherCubit.state). thenReturn (
status : WeatherStatus .failure,
child : MaterialApp (home : WeatherPage ()),
expect (find. byType ( WeatherError ), findsOneWidget);
testWidgets ( 'state is cached' , (tester) async {
when < dynamic > (() => hydratedStorage. read ( ' $ WeatherCubit ' )). thenReturn (
status : WeatherStatus .success,
temperatureUnits : TemperatureUnits .fahrenheit,
value : WeatherCubit ( MockWeatherRepository ()),
child : MaterialApp (home : WeatherPage ()),
expect (find. byType ( WeatherPopulated ), findsOneWidget);
testWidgets ( 'navigates to SettingsPage when settings icon is tapped' ,
when (() => weatherCubit.state). thenReturn ( WeatherState ());
child : MaterialApp (home : WeatherPage ()),
await tester. tap (find. byType ( IconButton ));
await tester. pumpAndSettle ();
expect (find. byType ( SettingsPage ), findsOneWidget);
testWidgets ( 'navigates to SearchPage when search button is tapped' ,
when (() => weatherCubit.state). thenReturn ( WeatherState ());
child : MaterialApp (home : WeatherPage ()),
await tester. tap (find. byType ( FloatingActionButton ));
await tester. pumpAndSettle ();
expect (find. byType ( SearchPage ), findsOneWidget);
testWidgets ( 'triggers refreshWeather on pull to refresh' , (tester) async {
when (() => weatherCubit.state). thenReturn (
status : WeatherStatus .success,
when (() => weatherCubit. refreshWeather ()). thenAnswer ((_) async {});
child : MaterialApp (home : WeatherPage ()),
await tester. pumpAndSettle ();
verify (() => weatherCubit. refreshWeather ()). called ( 1 );
testWidgets ( 'triggers fetch on search pop' , (tester) async {
when (() => weatherCubit.state). thenReturn ( WeatherState ());
when (() => weatherCubit. fetchWeather ( any ())). thenAnswer ((_) async {});
child : MaterialApp (home : WeatherPage ()),
await tester. tap (find. byType ( FloatingActionButton ));
await tester. pumpAndSettle ();
await tester. enterText (find. byType ( TextField ), 'Chicago' );
await tester. tap (find. byKey ( const Key ( 'searchPage_search_iconButton' )));
await tester. pumpAndSettle ();
verify (() => weatherCubit. fetchWeather ( 'Chicago' )). called ( 1 );
That’s it, we have completed the tutorial! 🎉
We can run the final app using the flutter run
command.
The full source code for this example, including unit and widget tests, can be found here .