Flutter Weather
Это содержимое пока не доступно на вашем языке.
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.
flutter_weather|-- lib/ |-- search/ |-- settings/ |-- theme/ |-- weather/ |-- main.dart|-- test/
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 namehttps://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.
flutter_weather|-- lib/|-- test/|-- packages/ |-- open_meteo_api/ |-- lib/ |-- src/ |-- models/ |-- location.dart |-- weather.dart |-- test/
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:
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;}
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:
class Weather { 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:
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:
library open_meteo_api;
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
.
name: open_meteo_apidescription: A Dart API Client for the Open-Meteo API.version: 1.0.0+1
environment: sdk: ">=3.6.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
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';
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;}
Here is our complete weather.dart
model file:
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;}
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.
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
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:
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 aFuture<Location>
getWeather
which returns aFuture<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( _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>);}
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,}) 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:
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); }}
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';
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), ); }); }); });}
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), ); }); }); });}
Next, let’s test our API client. We should test to ensure that our API client handles both API calls correctly, including edge cases.
// ignore_for_file: prefer_const_constructorsimport '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), ); }); }); });}
Finally, let’s gather test coverage to verify that we’ve covered each line of code with at least one test case.
flutter test --coveragegenhtml coverage/lcov.info -o coverageopen coverage/index.html
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
.
name: weather_repositorydescription: A Dart Repository which manages the weather domain.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: equatable: ^2.0.0 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
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.
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.
export 'weather.dart';
As before, we need to create a build.yaml
file with the following contents:
targets: $default: builders: json_serializable: options: field_rename: snake checked: true
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)
.
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 '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; } }}
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_constructorsimport '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, ), ); }); }); });}
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.
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!
name: flutter_weatherdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.0 flutter: sdk: flutter flutter_bloc: ^9.0.0 google_fonts: ^6.0.0 hydrated_bloc: ^10.0.0 json_annotation: ^4.8.1 path_provider: ^2.0.8 weather_repository: path: packages/weather_repository
dev_dependencies: bloc_test: ^10.0.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.
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' 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 a build.yaml
file for the business logic layer.
targets: $default: builders: json_serializable: options: field_rename: snake checked: true explicit_to_json: true
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
):
export 'weather.dart';
Then, let’s create a top-level weather barrel file (flutter_weather/lib/weather/weather.dart
);
export '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:
flutter_weather|-- lib/ |-- weather/ |-- cubit/ |-- weather_cubit.dart |-- weather_state.dart
There are four states our weather app can be in:
initial
before anything loadsloading
during the API callsuccess
if the API call is successfulfailure
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;}
@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];}
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 cityrefreshWeather()
retrieves a new weather object using the weather repository given the current weather statetoggleUnits()
toggles the state between Celsius and FahrenheitfromJson(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' 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 TemperatureConversion on double { 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
.
name: flutter_weatherdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.0 flutter: sdk: flutter flutter_bloc: ^9.0.0 google_fonts: ^6.0.0 hydrated_bloc: ^10.0.0 json_annotation: ^4.8.1 path_provider: ^2.0.8 weather_repository: path: packages/weather_repository
dev_dependencies: bloc_test: ^10.0.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/
// ignore_for_file: prefer_const_constructorsimport '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, ), ), ], ); }); });}
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});
@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.
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 { 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(), ), ); }, ), ], ), ); }}
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 { 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), ), ], ), ); }}
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});
@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, ), ], ); }}
This screen will display if there is an error.
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, ), ], ); }}
This screen will display as the application fetches the data.
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(), ), ], ); }}
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 { 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; final alpha = a.round(); final red = r.round(); final green = g.round(); final blue = b.round(); 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'}'''; }}
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';
void main() async { WidgetsFlutterBinding.ensureInitialized(); Bloc.observer = const WeatherBlocObserver(); HydratedBloc.storage = await HydratedStorage.build( storageDirectory: kIsWeb ? HydratedStorageDirectory.web : HydratedStorageDirectory((await getTemporaryDirectory()).path), ); 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' 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; } }}
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 {}
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); }); });}
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.