コンテンツにスキップ

Flutter Weather

このコンテンツはまだ日本語訳がありません。

advanced

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).

demo

Project Requirements

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)

Key Concepts

  • 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

Setup

To begin, create a new flutter project

Terminal window
flutter create flutter_weather

Project Structure

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.

flutter_weather
|-- lib/
|-- search/
|-- settings/
|-- theme/
|-- weather/
|-- main.dart
|-- test/

Architecture

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.)

Data Layer

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&current_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&current_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.

OpenMeteo API Client

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.

Terminal window
flutter create --template=package open_meteo_api

Weather Data Model

Next, let’s create location.dart and weather.dart which will contain the models for the location and weather API endpoint responses.

flutter_weather
|-- lib/
|-- test/
|-- packages/
|-- open_meteo_api/
|-- lib/
|-- src/
|-- models/
|-- location.dart
|-- weather.dart
|-- test/

Location Model

The location.dart model should store data returned by the location API, which looks like the following:

{
"results": [
{
"id": 4887398,
"name": "Chicago",
"latitude": 41.85003,
"longitude": -87.65005
}
]
}

Here’s the in-progress location.dart file which stores the above response:

packages/open_meteo_api/lib/src/models/location.dart
class Location {
const Location({
required this.id,
required this.name,
required this.latitude,
required this.longitude,
});
final int id;
final String name;
final double latitude;
final double longitude;
}

Weather Model

Next, let’s work on weather.dart. Our weather model should store data returned by the weather API, which looks like the following:

{
"current_weather": {
"temperature": 15.3,
"weathercode": 63
}
}

Here’s the in-progress weather.dart file which stores the above response:

packages/open_meteo_api/lib/src/models/weather.dart
class Weather {
const Weather({required this.temperature, required this.weatherCode});
final double temperature;
final double weatherCode;
}

Barrel Files

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:

packages/open_meteo_api/lib/src/models/models.dart
export 'location.dart';
export 'weather.dart';

Let’s also create a package level barrel file, open_meteo_api.dart

flutter_weather
|-- lib/
|-- test/
|-- packages/
|-- open_meteo_api/
|-- lib/
|-- src/
|-- models/
|-- location.dart
|-- weather.dart
|-- models.dart
|-- open_meteo_api.dart
|-- test/

In the top level, open_meteo_api.dart let’s export the models:

packages/open_meteo_api/lib/open_meteo_api.dart
library open_meteo_api;
export 'src/models/models.dart';

Setup

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.

packages/open_meteo_api/pubspec.yaml
name: open_meteo_api
description: A Dart API Client for the Open-Meteo API.
version: 1.0.0+1
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
http: ^1.0.0
json_annotation: ^4.6.0
dev_dependencies:
build_runner: ^2.0.0
json_serializable: ^6.3.1
mocktail: ^1.0.0
test: ^1.16.4

(De)Serialization

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

Location Model

Here is our complete location.dart model file:

packages/open_meteo_api/lib/src/models/location.dart
import 'package:json_annotation/json_annotation.dart';
part 'location.g.dart';
@JsonSerializable()
class Location {
const Location({
required this.id,
required this.name,
required this.latitude,
required this.longitude,
});
factory Location.fromJson(Map<String, dynamic> json) =>
_$LocationFromJson(json);
final int id;
final String name;
final double latitude;
final double longitude;
}

Weather Model

Here is our complete weather.dart model file:

packages/open_meteo_api/lib/src/models/weather.dart
import 'package:json_annotation/json_annotation.dart';
part 'weather.g.dart';
@JsonSerializable()
class Weather {
const Weather({required this.temperature, required this.weatherCode});
factory Weather.fromJson(Map<String, dynamic> json) =>
_$WeatherFromJson(json);
final double temperature;
@JsonKey(name: 'weathercode')
final double weatherCode;
}

Create Build File

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.

packages/open_meteo_api/build.yaml
targets:
$default:
builders:
source_gen|combining_builder:
options:
ignore_for_file:
- implicit_dynamic_parameter
json_serializable:
options:
field_rename: snake
create_to_json: false
checked: true

Code Generation

Let’s use build_runner to generate the code.

Terminal window
dart run build_runner build

build_runner should generate the location.g.dart and weather.g.dart files.

OpenMeteo API Client

Let’s create our API client in open_meteo_api_client.dart within the src directory. Our project structure should now look like this:

flutter_weather
|-- lib/
|-- test/
|-- packages/
|-- open_meteo_api/
|-- lib/
|-- src/
|-- models/
|-- location.dart
|-- location.g.dart
|-- weather.dart
|-- weather.g.dart
|-- models.dart
|-- open_meteo_api_client.dart
|-- open_meteo_api.dart
|-- test/

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:

packages/open_meteo_api/lib/src/open_meteo_api_client.dart
/// Finds a [Location] `/v1/search/?name=(query)`.
Future<Location> locationSearch(String query) async {
final locationRequest = Uri.https(
_baseUrlGeocoding,
'/v1/search',
{'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>);
}

Get Weather

Similarly, the getWeather method hits the weather API and throws WeatherRequestFailure errors as applicable. The completed method looks as follows:

packages/open_meteo_api/lib/src/open_meteo_api_client.dart
/// Fetches [Weather] for a given [latitude] and [longitude].
Future<Weather> getWeather({
required double latitude,
required double longitude,
}) async {
final weatherRequest = Uri.https(_baseUrlWeather, 'v1/forecast', {
'latitude': '$latitude',
'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:

packages/open_meteo_api/lib/src/open_meteo_api_client.dart
import 'dart:async';
import 'dart:convert';
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).
/// {@endtemplate}
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(
_baseUrlGeocoding,
'/v1/search',
{'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,
}) async {
final weatherRequest = Uri.https(_baseUrlWeather, 'v1/forecast', {
'latitude': '$latitude',
'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);
}
}

Barrel File Updates

Let’s wrap up this package by adding our API client to the barrel file.

packages/open_meteo_api/lib/open_meteo_api.dart
export 'src/models/models.dart';
export 'src/open_meteo_api_client.dart';

Unit Tests

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.

Setup

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.

Location Tests

packages/open_meteo_api/test/location_test.dart
import 'package:open_meteo_api/open_meteo_api.dart';
import 'package:test/test.dart';
void main() {
group('Location', () {
group('fromJson', () {
test('returns correct Location object', () {
expect(
Location.fromJson(
<String, dynamic>{
'id': 4887398,
'name': 'Chicago',
'latitude': 41.85003,
'longitude': -87.65005,
},
),
isA<Location>()
.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),
);
});
});
});
}

Weather Tests

packages/open_meteo_api/test/weather_test.dart
import 'package:open_meteo_api/open_meteo_api.dart';
import 'package:test/test.dart';
void main() {
group('Weather', () {
group('fromJson', () {
test('returns correct Weather object', () {
expect(
Weather.fromJson(
<String, dynamic>{'temperature': 15.3, 'weathercode': 63},
),
isA<Weather>()
.having((w) => w.temperature, 'temperature', 15.3)
.having((w) => w.weatherCode, 'weatherCode', 63),
);
});
});
});
}

API Client Tests

Next, let’s test our API client. We should test to ensure that our API client handles both API calls correctly, including edge cases.

packages/open_meteo_api/test/open_meteo_api_client_test.dart
// 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 {}
void main() {
group('OpenMeteoApiClient', () {
late http.Client httpClient;
late OpenMeteoApiClient apiClient;
setUpAll(() {
registerFallbackValue(FakeUri());
});
setUp(() {
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);
try {
await apiClient.locationSearch(query);
} catch (_) {}
verify(
() => httpClient.get(
Uri.https(
'geocoding-api.open-meteo.com',
'/v1/search',
{'name': query, 'count': '1'},
),
),
).called(1);
});
test('throws LocationRequestFailure on non-200 response', () async {
final response = MockResponse();
when(() => response.statusCode).thenReturn(400);
when(() => httpClient.get(any())).thenAnswer((_) async => response);
expect(
() 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);
await expectLater(
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);
await expectLater(
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(
'''
{
"results": [
{
"id": 4887398,
"name": "Chicago",
"latitude": 41.85003,
"longitude": -87.65005
}
]
}''',
);
when(() => httpClient.get(any())).thenAnswer((_) async => response);
final actual = await apiClient.locationSearch(query);
expect(
actual,
isA<Location>()
.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),
);
});
});
group('getWeather', () {
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);
try {
await apiClient.getWeather(latitude: latitude, longitude: longitude);
} catch (_) {}
verify(
() => httpClient.get(
Uri.https('api.open-meteo.com', 'v1/forecast', {
'latitude': '$latitude',
'longitude': '$longitude',
'current_weather': 'true',
}),
),
).called(1);
});
test('throws WeatherRequestFailure on non-200 response', () async {
final response = MockResponse();
when(() => response.statusCode).thenReturn(400);
when(() => httpClient.get(any())).thenAnswer((_) async => response);
expect(
() async => apiClient.getWeather(
latitude: latitude,
longitude: longitude,
),
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);
expect(
() async => apiClient.getWeather(
latitude: latitude,
longitude: longitude,
),
throwsA(isA<WeatherNotFoundFailure>()),
);
});
test('returns weather on valid response', () async {
final response = MockResponse();
when(() => response.statusCode).thenReturn(200);
when(() => response.body).thenReturn(
'''
{
"latitude": 43,
"longitude": -87.875,
"generationtime_ms": 0.2510547637939453,
"utc_offset_seconds": 0,
"timezone": "GMT",
"timezone_abbreviation": "GMT",
"elevation": 189,
"current_weather": {
"temperature": 15.3,
"windspeed": 25.8,
"winddirection": 310,
"weathercode": 63,
"time": "2022-09-12T01:00"
}
}
''',
);
when(() => httpClient.get(any())).thenAnswer((_) async => response);
final actual = await apiClient.getWeather(
latitude: latitude,
longitude: longitude,
);
expect(
actual,
isA<Weather>()
.having((w) => w.temperature, 'temperature', 15.3)
.having((w) => w.weatherCode, 'weatherCode', 63.0),
);
});
});
});
}

Test Coverage

Finally, let’s gather test coverage to verify that we’ve covered each line of code with at least one test case.

Terminal window
flutter test --coverage
genhtml coverage/lcov.info -o coverage
open coverage/index.html

Repository Layer

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.

Setup

Inside the packages directory, run the following command:

Terminal window
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.

packages/weather_repository/pubspec.yaml
name: weather_repository
description: A Dart Repository which manages the weather domain.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
equatable: ^2.0.5
json_annotation: ^4.6.0
open_meteo_api:
path: ../open_meteo_api
dev_dependencies:
build_runner: ^2.0.0
coverage: ^1.0.3
json_serializable: ^6.3.1
mocktail: ^1.0.0
test: ^1.16.4

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.

flutter_weather
|-- lib/
|-- test/
|-- packages/
|-- open_meteo_api/
|-- weather_repository/
|-- lib/
|-- src/
|-- models/
|-- models.dart
|-- weather.dart
|-- test/

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.

packages/weather_repository/lib/src/models/weather.dart
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'weather.g.dart';
enum WeatherCondition {
clear,
rainy,
cloudy,
snowy,
unknown,
}
@JsonSerializable()
class Weather extends Equatable {
const Weather({
required this.location,
required this.temperature,
required this.condition,
});
factory Weather.fromJson(Map<String, dynamic> json) =>
_$WeatherFromJson(json);
Map<String, dynamic> toJson() => _$WeatherToJson(this);
final String location;
final double temperature;
final WeatherCondition condition;
@override
List<Object> get props => [location, temperature, condition];
}

Update the barrel file we created previously to include the models.

packages/weather_repository/lib/src/models/models.dart
export 'weather.dart';

Create Build File

As before, we need to create a build.yaml file with the following contents:

packages/weather_repository/build.yaml
targets:
$default:
builders:
json_serializable:
options:
field_rename: snake
checked: true

Code Generation

As we have done previously, run the following command to generate the (de)serialization implementation.

Terminal window
dart run build_runner build

Barrel File

Let’s also create a package-level barrel file named packages/weather_repository/lib/weather_repository.dart to export our models:

packages/weather_repository/lib/weather_repository.dart
library weather_repository;
export 'src/models/models.dart';

Weather Repository

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).

Setup

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:

packages/weather_repository/lib/src/weather_repository.dart
import 'dart:async';
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,
);
return Weather(
temperature: weather.temperature,
location: location.name,
condition: weather.weatherCode.toInt().toCondition,
);
}
}
extension on int {
WeatherCondition get toCondition {
switch (this) {
case 0:
return WeatherCondition.clear;
case 1:
case 2:
case 3:
case 45:
case 48:
return WeatherCondition.cloudy;
case 51:
case 53:
case 55:
case 56:
case 57:
case 61:
case 63:
case 65:
case 66:
case 67:
case 80:
case 81:
case 82:
case 95:
case 96:
case 99:
return WeatherCondition.rainy;
case 71:
case 73:
case 75:
case 77:
case 85:
case 86:
return WeatherCondition.snowy;
default:
return WeatherCondition.unknown;
}
}
}

Barrel File

Update the barrel file we created previously.

packages/weather_repository/lib/weather_repository.dart
export 'src/models/models.dart';
export 'src/weather_repository.dart';

Unit Tests

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.

packages/weather_repository/test/weather_repository_test.dart
// 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 {}
void main() {
group('WeatherRepository', () {
late open_meteo_api.OpenMeteoApiClient weatherApiClient;
late WeatherRepository weatherRepository;
setUp(() {
weatherApiClient = MockOpenMeteoApiClient();
weatherRepository = WeatherRepository(
weatherApiClient: weatherApiClient,
);
});
group('constructor', () {
test('instantiates internal weather api client when not injected', () {
expect(WeatherRepository(), isNotNull);
});
});
group('getWeather', () {
const city = 'chicago';
const latitude = 41.85003;
const longitude = -87.65005;
test('calls locationSearch with correct city', () async {
try {
await weatherRepository.getWeather(city);
} catch (_) {}
verify(() => weatherApiClient.locationSearch(city)).called(1);
});
test('throws when locationSearch fails', () async {
final exception = Exception('oops');
when(() => weatherApiClient.locationSearch(any())).thenThrow(exception);
expect(
() async => weatherRepository.getWeather(city),
throwsA(exception),
);
});
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(
(_) async => location,
);
try {
await weatherRepository.getWeather(city);
} catch (_) {}
verify(
() => weatherApiClient.getWeather(
latitude: latitude,
longitude: longitude,
),
).called(1);
});
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(
(_) async => location,
);
when(
() => weatherApiClient.getWeather(
latitude: any(named: 'latitude'),
longitude: any(named: 'longitude'),
),
).thenThrow(exception);
expect(
() async => weatherRepository.getWeather(city),
throwsA(exception),
);
});
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(
(_) async => location,
);
when(
() => weatherApiClient.getWeather(
latitude: any(named: 'latitude'),
longitude: any(named: 'longitude'),
),
).thenAnswer((_) async => weather);
final actual = await weatherRepository.getWeather(city);
expect(
actual,
Weather(
temperature: 42.42,
location: 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(
(_) async => location,
);
when(
() => weatherApiClient.getWeather(
latitude: any(named: 'latitude'),
longitude: any(named: 'longitude'),
),
).thenAnswer((_) async => weather);
final actual = await weatherRepository.getWeather(city);
expect(
actual,
Weather(
temperature: 42.42,
location: 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(
(_) async => location,
);
when(
() => weatherApiClient.getWeather(
latitude: any(named: 'latitude'),
longitude: any(named: 'longitude'),
),
).thenAnswer((_) async => weather);
final actual = await weatherRepository.getWeather(city);
expect(
actual,
Weather(
temperature: 42.42,
location: 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(
(_) async => location,
);
when(
() => weatherApiClient.getWeather(
latitude: any(named: 'latitude'),
longitude: any(named: 'longitude'),
),
).thenAnswer((_) async => weather);
final actual = await weatherRepository.getWeather(city);
expect(
actual,
Weather(
temperature: 42.42,
location: 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(
(_) async => location,
);
when(
() => weatherApiClient.getWeather(
latitude: any(named: 'latitude'),
longitude: any(named: 'longitude'),
),
).thenAnswer((_) async => weather);
final actual = await weatherRepository.getWeather(city);
expect(
actual,
Weather(
temperature: 42.42,
location: city,
condition: WeatherCondition.unknown,
),
);
});
});
});
}

Business Logic Layer

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.

Setup

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!

pubspec.yaml
name: flutter_weather
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
bloc: ^8.1.0
equatable: ^2.0.5
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
google_fonts: ^4.0.0
hydrated_bloc: ^9.0.0
json_annotation: ^4.8.1
path_provider: ^2.0.8
weather_repository:
path: packages/weather_repository
dev_dependencies:
bloc_test: ^9.1.0
build_runner: ^2.0.0
flutter_test:
sdk: flutter
json_serializable: ^6.0.0
mocktail: ^1.0.0
flutter:
uses-material-design: true
assets:
- assets/

Next, we will be working on the application layer within the weather feature directory.

Weather Model

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:

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'
as weather_repository;
part 'weather.g.dart';
enum TemperatureUnits { fahrenheit, celsius }
extension TemperatureUnitsX on TemperatureUnits {
bool get isFahrenheit => this == TemperatureUnits.fahrenheit;
bool get isCelsius => this == TemperatureUnits.celsius;
}
@JsonSerializable()
class Temperature extends Equatable {
const Temperature({required this.value});
factory Temperature.fromJson(Map<String, dynamic> json) =>
_$TemperatureFromJson(json);
final double value;
Map<String, dynamic> toJson() => _$TemperatureToJson(this);
@override
List<Object> get props => [value];
}
@JsonSerializable()
class Weather extends Equatable {
const Weather({
required this.condition,
required this.lastUpdated,
required this.location,
required this.temperature,
});
factory Weather.fromJson(Map<String, dynamic> json) =>
_$WeatherFromJson(json);
factory Weather.fromRepository(weather_repository.Weather weather) {
return 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),
location: '--',
);
final WeatherCondition condition;
final DateTime lastUpdated;
final String location;
final Temperature temperature;
@override
List<Object> get props => [condition, lastUpdated, location, temperature];
Map<String, dynamic> toJson() => _$WeatherToJson(this);
Weather copyWith({
WeatherCondition? condition,
DateTime? lastUpdated,
String? location,
Temperature? temperature,
}) {
return Weather(
condition: condition ?? this.condition,
lastUpdated: lastUpdated ?? this.lastUpdated,
location: location ?? this.location,
temperature: temperature ?? this.temperature,
);
}
}

Create Build File

Create a build.yaml file for the business logic layer.

build.yaml
targets:
$default:
builders:
json_serializable:
options:
field_rename: snake
checked: true
explicit_to_json: true

Code Generation

Run build_runner to generate the (de)serialization implementations.

Terminal window
dart run build_runner build

Barrel File

Let’s export our models from the barrel file (flutter_weather/lib/weather/models/models.dart):

lib/weather/models/models.dart
export 'weather.dart';

Weather

We will use HydratedCubit to enable our app to remember its application state, even after it’s been closed and reopened.

Weather State

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:

flutter_weather
|-- lib/
|-- weather/
|-- cubit/
|-- weather_cubit.dart
|-- weather_state.dart

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:

lib/weather/cubit/weather_state.dart
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;
}
@JsonSerializable()
final class WeatherState extends Equatable {
WeatherState({
this.status = WeatherStatus.initial,
this.temperatureUnits = TemperatureUnits.celsius,
Weather? weather,
}) : weather = weather ?? Weather.empty;
factory WeatherState.fromJson(Map<String, dynamic> json) =>
_$WeatherStateFromJson(json);
final WeatherStatus status;
final Weather weather;
final TemperatureUnits temperatureUnits;
WeatherState copyWith({
WeatherStatus? status,
TemperatureUnits? temperatureUnits,
Weather? weather,
}) {
return WeatherState(
status: status ?? this.status,
temperatureUnits: temperatureUnits ?? this.temperatureUnits,
weather: weather ?? this.weather,
);
}
Map<String, dynamic> toJson() => _$WeatherStateToJson(this);
@override
List<Object?> get props => [status, temperatureUnits, weather];
}

Weather Cubit

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
lib/weather/cubit/weather_cubit.dart
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'
show WeatherRepository;
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));
try {
final weather = Weather.fromRepository(
await _weatherRepository.getWeather(city),
);
final units = state.temperatureUnits;
final value = units.isFahrenheit
? weather.temperature.value.toFahrenheit()
: weather.temperature.value;
emit(
state.copyWith(
status: WeatherStatus.success,
temperatureUnits: units,
weather: weather.copyWith(temperature: Temperature(value: value)),
),
);
} on Exception {
emit(state.copyWith(status: WeatherStatus.failure));
}
}
Future<void> refreshWeather() async {
if (!state.status.isSuccess) return;
if (state.weather == Weather.empty) return;
try {
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;
emit(
state.copyWith(
status: WeatherStatus.success,
temperatureUnits: units,
weather: weather.copyWith(temperature: Temperature(value: value)),
),
);
} on Exception {
emit(state);
}
}
void toggleUnits() {
final units = state.temperatureUnits.isFahrenheit
? TemperatureUnits.celsius
: TemperatureUnits.fahrenheit;
if (!state.status.isSuccess) {
emit(state.copyWith(temperatureUnits: units));
return;
}
final weather = state.weather;
if (weather != Weather.empty) {
final temperature = weather.temperature;
final value = units.isCelsius
? temperature.value.toCelsius()
: temperature.value.toFahrenheit();
emit(
state.copyWith(
temperatureUnits: units,
weather: weather.copyWith(temperature: Temperature(value: value)),
),
);
}
}
@override
WeatherState fromJson(Map<String, dynamic> json) =>
WeatherState.fromJson(json);
@override
Map<String, dynamic> toJson(WeatherState state) => state.toJson();
}
extension on double {
double toFahrenheit() => (this * 9 / 5) + 32;
double toCelsius() => (this - 32) * 5 / 9;
}

Unit Tests

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.

pubspec.yaml
name: flutter_weather
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
bloc: ^8.1.0
equatable: ^2.0.5
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
google_fonts: ^4.0.0
hydrated_bloc: ^9.0.0
json_annotation: ^4.8.1
path_provider: ^2.0.8
weather_repository:
path: packages/weather_repository
dev_dependencies:
bloc_test: ^9.1.0
build_runner: ^2.0.0
flutter_test:
sdk: flutter
json_serializable: ^6.0.0
mocktail: ^1.0.0
flutter:
uses-material-design: true
assets:
- assets/

Weather Cubit Tests

test/weather/cubit/weather_cubit_test.dart
// 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'
as weather_repository;
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 {}
void main() {
initHydratedStorage();
group('WeatherCubit', () {
late weather_repository.Weather weather;
late weather_repository.WeatherRepository weatherRepository;
late WeatherCubit weatherCubit;
setUp(() async {
weather = MockWeather();
weatherRepository = MockWeatherRepository();
when(() => weather.condition).thenReturn(weatherCondition);
when(() => weather.location).thenReturn(weatherLocation);
when(() => weather.temperature).thenReturn(weatherTemperature);
when(
() => 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);
expect(
weatherCubit.fromJson(weatherCubit.toJson(weatherCubit.state)),
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: (_) {
verify(() => weatherRepository.getWeather(weatherLocation)).called(1);
},
);
blocTest<WeatherCubit, WeatherState>(
'emits [loading, failure] when getWeather throws',
setUp: () {
when(
() => 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),
expect: () => <dynamic>[
WeatherState(status: WeatherStatus.loading),
isA<WeatherState>()
.having((w) => w.status, 'status', WeatherStatus.success)
.having(
(w) => w.weather,
'weather',
isA<Weather>()
.having((w) => w.lastUpdated, 'lastUpdated', isNotNull)
.having((w) => w.condition, 'condition', weatherCondition)
.having(
(w) => w.temperature,
'temperature',
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),
expect: () => <dynamic>[
WeatherState(
status: WeatherStatus.loading,
temperatureUnits: TemperatureUnits.fahrenheit,
),
isA<WeatherState>()
.having((w) => w.status, 'status', WeatherStatus.success)
.having(
(w) => w.weather,
'weather',
isA<Weather>()
.having((w) => w.lastUpdated, 'lastUpdated', isNotNull)
.having((w) => w.condition, 'condition', weatherCondition)
.having(
(w) => w.temperature,
'temperature',
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>[],
verify: (_) {
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>[],
verify: (_) {
verifyNever(() => weatherRepository.getWeather(any()));
},
);
blocTest<WeatherCubit, WeatherState>(
'invokes getWeather with correct location',
build: () => weatherCubit,
seed: () => WeatherState(
status: WeatherStatus.success,
weather: Weather(
location: weatherLocation,
temperature: Temperature(value: weatherTemperature),
lastUpdated: DateTime(2020),
condition: weatherCondition,
),
),
act: (cubit) => cubit.refreshWeather(),
verify: (_) {
verify(() => weatherRepository.getWeather(weatherLocation)).called(1);
},
);
blocTest<WeatherCubit, WeatherState>(
'emits nothing when exception is thrown',
setUp: () {
when(
() => weatherRepository.getWeather(any()),
).thenThrow(Exception('oops'));
},
build: () => weatherCubit,
seed: () => WeatherState(
status: WeatherStatus.success,
weather: Weather(
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,
weather: Weather(
location: weatherLocation,
temperature: Temperature(value: 0),
lastUpdated: DateTime(2020),
condition: weatherCondition,
),
),
act: (cubit) => cubit.refreshWeather(),
expect: () => <Matcher>[
isA<WeatherState>()
.having((w) => w.status, 'status', WeatherStatus.success)
.having(
(w) => w.weather,
'weather',
isA<Weather>()
.having((w) => w.lastUpdated, 'lastUpdated', isNotNull)
.having((w) => w.condition, 'condition', weatherCondition)
.having(
(w) => w.temperature,
'temperature',
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,
weather: Weather(
location: weatherLocation,
temperature: Temperature(value: 0),
lastUpdated: DateTime(2020),
condition: weatherCondition,
),
),
act: (cubit) => cubit.refreshWeather(),
expect: () => <Matcher>[
isA<WeatherState>()
.having((w) => w.status, 'status', WeatherStatus.success)
.having(
(w) => w.weather,
'weather',
isA<Weather>()
.having((w) => w.lastUpdated, 'lastUpdated', isNotNull)
.having((w) => w.condition, 'condition', weatherCondition)
.having(
(w) => w.temperature,
'temperature',
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,
weather: Weather(
location: weatherLocation,
temperature: Temperature(value: weatherTemperature),
lastUpdated: DateTime(2020),
condition: WeatherCondition.rainy,
),
),
act: (cubit) => cubit.toggleUnits(),
expect: () => <WeatherState>[
WeatherState(
status: WeatherStatus.success,
weather: Weather(
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,
weather: Weather(
location: weatherLocation,
temperature: Temperature(value: weatherTemperature),
lastUpdated: DateTime(2020),
condition: WeatherCondition.rainy,
),
),
act: (cubit) => cubit.toggleUnits(),
expect: () => <WeatherState>[
WeatherState(
status: WeatherStatus.success,
temperatureUnits: TemperatureUnits.fahrenheit,
weather: Weather(
location: weatherLocation,
temperature: Temperature(
value: weatherTemperature.toFahrenheit(),
),
lastUpdated: DateTime(2020),
condition: WeatherCondition.rainy,
),
),
],
);
});
});
}
extension on double {
double toFahrenheit() => (this * 9 / 5) + 32;
double toCelsius() => (this - 32) * 5 / 9;
}

Presentation Layer

Weather Page

We will start with the WeatherPage which uses BlocProvider in order to provide an instance of the WeatherCubit to the widget tree.

lib/weather/view/weather_page.dart
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});
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.of(context).push<void>(
SettingsPage.route(),
),
),
],
),
body: Center(
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(
weather: state.weather,
units: state.temperatureUnits,
onRefresh: () {
return context.read<WeatherCubit>().refreshWeather();
},
),
};
},
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.search, semanticLabel: 'Search'),
onPressed: () async {
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.

SettingsPage

The settings page allows users to update their preferences for the temperature units.

lib/settings/view/settings_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_weather/weather/weather.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage._();
static Route<void> route() {
return MaterialPageRoute<void>(
builder: (_) => const SettingsPage._(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: <Widget>[
BlocBuilder<WeatherCubit, WeatherState>(
buildWhen: (previous, current) =>
previous.temperatureUnits != current.temperatureUnits,
builder: (context, state) {
return ListTile(
title: const Text('Temperature Units'),
isThreeLine: true,
subtitle: const Text(
'Use metric measurements for temperature units.',
),
trailing: Switch(
value: state.temperatureUnits.isCelsius,
onChanged: (_) => context.read<WeatherCubit>().toggleUnits(),
),
);
},
),
],
),
);
}
}

SearchPage

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.

lib/search/view/search_page.dart
import 'package:flutter/material.dart';
class SearchPage extends StatefulWidget {
const SearchPage._();
static Route<String> route() {
return MaterialPageRoute(builder: (_) => const SearchPage._());
}
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final TextEditingController _textController = TextEditingController();
String get _text => _textController.text;
@override
void dispose() {
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('City Search')),
body: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: TextField(
controller: _textController,
decoration: const InputDecoration(
labelText: 'City',
hintText: 'Chicago',
),
),
),
),
IconButton(
key: const Key('searchPage_search_iconButton'),
icon: const Icon(Icons.search, semanticLabel: 'Submit'),
onPressed: () => Navigator.of(context).pop(_text),
),
],
),
);
}
}

Weather Widgets

The app will display different screens depending on the four possible states of the WeatherCubit.

WeatherEmpty

This screen will show when there is no data to display because the user has not yet selected a city.

lib/weather/widgets/weather_empty.dart
import 'package:flutter/material.dart';
class WeatherEmpty extends StatelessWidget {
const WeatherEmpty({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🏙️', style: TextStyle(fontSize: 64)),
Text(
'Please Select a City!',
style: theme.textTheme.headlineSmall,
),
],
);
}
}

WeatherError

This screen will display if there is an error.

lib/weather/widgets/weather_error.dart
import 'package:flutter/material.dart';
class WeatherError extends StatelessWidget {
const WeatherError({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🙈', style: TextStyle(fontSize: 64)),
Text(
'Something went wrong!',
style: theme.textTheme.headlineSmall,
),
],
);
}
}

WeatherLoading

This screen will display as the application fetches the data.

lib/weather/widgets/weather_loading.dart
import 'package:flutter/material.dart';
class WeatherLoading extends StatelessWidget {
const WeatherLoading({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('⛅', style: TextStyle(fontSize: 64)),
Text(
'Loading Weather',
style: theme.textTheme.headlineSmall,
),
const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
],
);
}
}

WeatherPopulated

This screen will display after the user has selected a city and we have retrieved the data.

lib/weather/widgets/weather_populated.dart
import 'package:flutter/material.dart';
import 'package:flutter_weather/weather/weather.dart';
class WeatherPopulated extends StatelessWidget {
const WeatherPopulated({
required this.weather,
required this.units,
required this.onRefresh,
super.key,
});
final Weather weather;
final TemperatureUnits units;
final ValueGetter<Future<void>> onRefresh;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Stack(
children: [
_WeatherBackground(),
RefreshIndicator(
onRefresh: onRefresh,
child: Align(
alignment: const Alignment(0, -1 / 3),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
clipBehavior: Clip.none,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 48),
_WeatherIcon(condition: weather.condition),
Text(
weather.location,
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w200,
),
),
Text(
weather.formattedTemperature(units),
style: theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'''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;
@override
Widget build(BuildContext context) {
return Text(
condition.toEmoji,
style: const TextStyle(fontSize: _iconSize),
);
}
}
extension on WeatherCondition {
String get toEmoji {
switch (this) {
case WeatherCondition.clear:
return '☀️';
case WeatherCondition.rainy:
return '🌧️';
case WeatherCondition.cloudy:
return '☁️';
case WeatherCondition.snowy:
return '🌨️';
case WeatherCondition.unknown:
return '❓';
}
}
}
class _WeatherBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primaryContainer;
return SizedBox.expand(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.25, 0.75, 0.90, 1.0],
colors: [
color,
color.brighten(),
color.brighten(33),
color.brighten(50),
],
),
),
),
);
}
}
extension on Color {
Color brighten([int percent = 10]) {
assert(
1 <= percent && percent <= 100,
'percentage must be between 1 and 100',
);
final p = percent / 100;
return Color.fromARGB(
alpha,
red + ((255 - red) * p).round(),
green + ((255 - green) * p).round(),
blue + ((255 - blue) * p).round(),
);
}
}
extension on Weather {
String formattedTemperature(TemperatureUnits units) {
return '''${temperature.value.toStringAsPrecision(2)}°${units.isCelsius ? 'C' : 'F'}''';
}
}

Barrel File

Let’s add these states to a barrel file to clean up our imports.

lib/weather/widgets/widgets.dart
export 'weather_empty.dart';
export 'weather_error.dart';
export 'weather_loading.dart';
export 'weather_populated.dart';

Entrypoint

Our main.dart file should initialize our WeatherApp and BlocObserver (for debugging purposes), as well as setup our HydratedStorage to persist state across sessions.

lib/main.dart
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';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Bloc.observer = const WeatherBlocObserver();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? 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.

lib/app.dart
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'
show WeatherRepository;
class WeatherApp extends StatelessWidget {
const WeatherApp({required WeatherRepository weatherRepository, super.key})
: _weatherRepository = weatherRepository;
final WeatherRepository _weatherRepository;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => WeatherCubit(_weatherRepository),
child: const WeatherAppView(),
);
}
}
class WeatherAppView extends StatelessWidget {
const WeatherAppView({super.key});
@override
Widget build(BuildContext context) {
final seedColor = context.select(
(WeatherCubit cubit) => cubit.state.weather.toColor,
);
return MaterialApp(
theme: ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
),
colorScheme: ColorScheme.fromSeed(seedColor: seedColor),
textTheme: GoogleFonts.rajdhaniTextTheme(),
),
home: const WeatherPage(),
);
}
}
extension on Weather {
Color get toColor {
switch (condition) {
case WeatherCondition.clear:
return Colors.yellow;
case WeatherCondition.snowy:
return Colors.lightBlueAccent;
case WeatherCondition.cloudy:
return Colors.blueGrey;
case WeatherCondition.rainy:
return Colors.indigoAccent;
case WeatherCondition.unknown:
return Colors.cyan;
}
}
}

Widget Tests

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.

test/weather/view/weather_page_test.dart
// 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 {}
void main() {
initHydratedStorage();
group('WeatherPage', () {
final weather = Weather(
temperature: Temperature(value: 4.2),
condition: WeatherCondition.cloudy,
lastUpdated: DateTime(2020),
location: 'London',
);
late WeatherCubit weatherCubit;
setUp(() {
weatherCubit = MockWeatherCubit();
});
testWidgets('renders WeatherEmpty for WeatherStatus.initial',
(tester) async {
when(() => weatherCubit.state).thenReturn(WeatherState());
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
child: MaterialApp(home: WeatherPage()),
),
);
expect(find.byType(WeatherEmpty), findsOneWidget);
});
testWidgets('renders WeatherLoading for WeatherStatus.loading',
(tester) async {
when(() => weatherCubit.state).thenReturn(
WeatherState(
status: WeatherStatus.loading,
),
);
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
child: MaterialApp(home: WeatherPage()),
),
);
expect(find.byType(WeatherLoading), findsOneWidget);
});
testWidgets('renders WeatherPopulated for WeatherStatus.success',
(tester) async {
when(() => weatherCubit.state).thenReturn(
WeatherState(
status: WeatherStatus.success,
weather: weather,
),
);
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
child: MaterialApp(home: WeatherPage()),
),
);
expect(find.byType(WeatherPopulated), findsOneWidget);
});
testWidgets('renders WeatherError for WeatherStatus.failure',
(tester) async {
when(() => weatherCubit.state).thenReturn(
WeatherState(
status: WeatherStatus.failure,
),
);
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
child: MaterialApp(home: WeatherPage()),
),
);
expect(find.byType(WeatherError), findsOneWidget);
});
testWidgets('state is cached', (tester) async {
when<dynamic>(() => hydratedStorage.read('$WeatherCubit')).thenReturn(
WeatherState(
status: WeatherStatus.success,
weather: weather,
temperatureUnits: TemperatureUnits.fahrenheit,
).toJson(),
);
await tester.pumpWidget(
BlocProvider.value(
value: WeatherCubit(MockWeatherRepository()),
child: MaterialApp(home: WeatherPage()),
),
);
expect(find.byType(WeatherPopulated), findsOneWidget);
});
testWidgets('navigates to SettingsPage when settings icon is tapped',
(tester) async {
when(() => weatherCubit.state).thenReturn(WeatherState());
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
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',
(tester) async {
when(() => weatherCubit.state).thenReturn(WeatherState());
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
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(
WeatherState(
status: WeatherStatus.success,
weather: weather,
),
);
when(() => weatherCubit.refreshWeather()).thenAnswer((_) async {});
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
child: MaterialApp(home: WeatherPage()),
),
);
await tester.fling(
find.text('London'),
const Offset(0, 500),
1000,
);
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 {});
await tester.pumpWidget(
BlocProvider.value(
value: weatherCubit,
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);
});
});
}

Summary

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.