In the Beginner Guide, we
explored the core philosophy of BLoC and how to leverage the Single State + Enum
pattern to avoid screen flickering and preserve data.
Now, let’s put these professional state management practices into action by
building a fully-featured, production-ready weather app from scratch using BLoC,
the repository pattern, and HTTP integrations.
This walkthrough will cover:
- Modeling domain entities with value equality (
Equatable)
- Fetching and parsing multi-step HTTP requests in a repository
- Creating a single-state BLoC using status enums
- Implementing silent pulls-to-refresh
- Wiring the BLoC and repository dependencies into the Flutter UI
- Using
BlocObserver to log and trace transitions
To get started, create a new Flutter app by running the following command:
flutter create my_new_app
Alternatively, if you want support for over-the-air updates from day 1, you can
install the Shorebird CLI and run:
shorebird create my_new_app
Add the following packages to your pubspec.yaml file:
dependencies:
flutter_bloc: ^9.1.0
equatable: ^2.0.5
http: ^1.2.0
flutter_bloc wires streams into the widget tree with BlocProvider and
BlocBuilder.
equatable gives your events, states, and models value equality.
http is used by the repository to call the weather API.
This implementation includes two user actions:
- Fetch shows a spinner and replaces the UI state.
- Refresh updates silently in the background, keeping the existing content
visible while indicating a loading overlay.
A small, UI-friendly model makes state rendering predictable. It also keeps your
UI independent of the raw API response shape.
// lib/features/weather/models/weather.dart
import 'package:equatable/equatable.dart';
/// A minimal, UI-friendly weather model for the BLoC tutorial.
final class Weather extends Equatable {
final String city;
final double tempC;
final int conditionCode;
final String condition;
final DateTime fetchedAt;
const Weather({
required this.city,
required this.tempC,
required this.conditionCode,
required this.condition,
required this.fetchedAt,
});
factory Weather.fromOpenMeteo({
required String city,
required Map<String, dynamic> json,
}) {
final current = (json['current'] as Map?)?.cast<String, dynamic>();
if (current == null) {
throw const FormatException('Missing "current" in weather response.');
}
final temp = current['temperature_2m'];
final code = current['weather_code'];
final time = current['time'];
if (temp == null || code == null || time == null) {
throw const FormatException('Weather response missing required fields.');
}
final fetchedAt = DateTime.tryParse(time.toString());
if (fetchedAt == null) {
throw const FormatException('Invalid "time" in weather response.');
}
final codeInt = (code as num).toInt();
return Weather(
city: city,
tempC: (temp as num).toDouble(),
conditionCode: codeInt,
condition: WeatherCondition.fromCode(codeInt).label,
fetchedAt: fetchedAt.toLocal(),
);
}
@override
List<Object> get props => [city, tempC, conditionCode, condition, fetchedAt];
}
/// Weather condition mapping for Open-Meteo weather codes.
enum WeatherCondition {
clear('Clear'),
mainlyClear('Mainly clear'),
partlyCloudy('Partly cloudy'),
overcast('Overcast'),
fog('Fog'),
depositingRimeFog('Depositing rime fog'),
drizzleLight('Light drizzle'),
drizzleModerate('Moderate drizzle'),
drizzleDense('Dense drizzle'),
freezingDrizzleLight('Light freezing drizzle'),
freezingDrizzleDense('Dense freezing drizzle'),
rainSlight('Slight rain'),
rainModerate('Moderate rain'),
rainHeavy('Heavy rain'),
freezingRainLight('Light freezing rain'),
freezingRainHeavy('Heavy freezing rain'),
snowSlight('Slight snow'),
snowModerate('Moderate snow'),
snowHeavy('Heavy snow'),
snowGrains('Snow grains'),
rainShowersSlight('Slight rain showers'),
rainShowersModerate('Moderate rain showers'),
rainShowersViolent('Violent rain showers'),
snowShowersSlight('Slight snow showers'),
snowShowersHeavy('Heavy snow showers'),
thunderstormSlight('Thunderstorm'),
thunderstormSlightHail('Thunderstorm with slight hail'),
thunderstormHeavyHail('Thunderstorm with heavy hail'),
unknown('Unknown');
final String label;
const WeatherCondition(this.label);
static WeatherCondition fromCode(int code) => switch (code) {
0 => WeatherCondition.clear,
1 => WeatherCondition.mainlyClear,
2 => WeatherCondition.partlyCloudy,
3 => WeatherCondition.overcast,
45 => WeatherCondition.fog,
48 => WeatherCondition.depositingRimeFog,
51 => WeatherCondition.drizzleLight,
53 => WeatherCondition.drizzleModerate,
55 => WeatherCondition.drizzleDense,
56 => WeatherCondition.freezingDrizzleLight,
57 => WeatherCondition.freezingDrizzleDense,
61 => WeatherCondition.rainSlight,
63 => WeatherCondition.rainModerate,
65 => WeatherCondition.rainHeavy,
66 => WeatherCondition.freezingRainLight,
67 => WeatherCondition.freezingRainHeavy,
71 => WeatherCondition.snowSlight,
73 => WeatherCondition.snowModerate,
75 => WeatherCondition.snowHeavy,
77 => WeatherCondition.snowGrains,
80 => WeatherCondition.rainShowersSlight,
81 => WeatherCondition.rainShowersModerate,
82 => WeatherCondition.rainShowersViolent,
85 => WeatherCondition.snowShowersSlight,
86 => WeatherCondition.snowShowersHeavy,
95 => WeatherCondition.thunderstormSlight,
96 => WeatherCondition.thunderstormSlightHail,
99 => WeatherCondition.thunderstormHeavyHail,
_ => WeatherCondition.unknown,
};
}
A key detail here is using Equatable. It prevents subtle UI churn because
identical values compare equal, avoiding unnecessary rebuilds.
The repository has one job: take a city name, search coordinates, fetch the
forecast, and return a structured Weather model (or throw an error).
// lib/features/weather/repositories/weather_repository.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../models/weather.dart';
class WeatherRepository {
final http.Client _client;
WeatherRepository({http.Client? client}) : _client = client ?? http.Client();
Future<Weather> fetchWeather(String city) async {
final normalized = city.trim();
if (normalized.isEmpty) {
throw const FormatException('City cannot be empty.');
}
final coords = await _geocodeCity(normalized);
final json = await _fetchCurrentWeather(coords.latitude, coords.longitude);
return Weather.fromOpenMeteo(city: coords.displayName, json: json);
}
Future<_GeoResult> _geocodeCity(String city) async {
final uri = Uri.https(
'geocoding-api.open-meteo.com',
'/v1/search',
<String, String>{
'name': city,
'count': '1',
'language': 'en',
'format': 'json',
},
);
final res = await _client.get(uri, headers: _headers());
if (res.statusCode != 200) {
throw HttpException('Geocoding failed (HTTP ${res.statusCode}).');
}
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) {
throw const FormatException('Invalid geocoding response.');
}
final results = body['results'];
if (results is! List || results.isEmpty) {
throw StateError('No results found for "$city".');
}
final first = results.first;
if (first is! Map) {
throw const FormatException('Invalid geocoding result.');
}
final lat = first['latitude'];
final lon = first['longitude'];
final name = first['name'];
if (lat == null || lon == null || name == null) {
throw const FormatException('Geocoding result missing fields.');
}
final admin1 = first['admin1'];
final country = first['country'];
final displayName = [
name.toString(),
if (admin1 != null) admin1.toString(),
if (country != null) country.toString(),
].join(', ');
return _GeoResult(
latitude: (lat as num).toDouble(),
longitude: (lon as num).toDouble(),
displayName: displayName,
);
}
Future<Map<String, dynamic>> _fetchCurrentWeather(
double latitude,
double longitude,
) async {
final uri =
Uri.https('api.open-meteo.com', '/v1/forecast', <String, String>{
'latitude': latitude.toString(),
'longitude': longitude.toString(),
'current': 'temperature_2m,weather_code',
'temperature_unit': 'celsius',
'timezone': 'auto',
});
final res = await _client.get(uri, headers: _headers());
if (res.statusCode != 200) {
throw HttpException('Weather fetch failed (HTTP ${res.statusCode}).');
}
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) {
throw const FormatException('Invalid weather response.');
}
return body;
}
Map<String, String> _headers() => const {'Accept': 'application/json'};
}
final class _GeoResult {
final double latitude;
final double longitude;
final String displayName;
const _GeoResult({
required this.latitude,
required this.longitude,
required this.displayName,
});
}
Separating this logic ensures that if you swap weather providers or add caching
layers later, your BLoC and UI layers remain unaffected.
Next, we define our events. Events should name actions that happened.
// lib/features/weather/bloc/weather_event.dart
part of 'weather_bloc.dart';
sealed class WeatherEvent extends Equatable {
const WeatherEvent();
@override
List<Object> get props => [];
}
final class WeatherFetchRequested extends WeatherEvent {
final String city;
const WeatherFetchRequested(this.city);
@override
List<Object> get props => [city];
}
final class WeatherRefreshRequested extends WeatherEvent {
final String city;
const WeatherRefreshRequested(this.city);
@override
List<Object> get props => [city];
}
Instead of creating separate classes for each state (which breaks data retention
during refreshes), we use a Single State Class with a status enum.
// lib/features/weather/bloc/weather_state.dart
part of 'weather_bloc.dart';
enum WeatherStatus { initial, loading, success, failure }
class WeatherState extends Equatable {
final WeatherStatus status;
final Weather? weather;
final String? errorMessage;
const WeatherState({
this.status = WeatherStatus.initial,
this.weather,
this.errorMessage,
});
WeatherState copyWith({
WeatherStatus? status,
Weather? weather,
String? errorMessage,
}) {
return WeatherState(
status: status ?? this.status,
weather: weather ?? this.weather,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, weather, errorMessage];
}
Now we write the business logic orchestrator. We use copyWith to emit state
modifications. Notice that during refreshes, we preserve the existing weather
data.
// lib/features/weather/bloc/weather_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../models/weather.dart';
import '../repositories/weather_repository.dart';
part 'weather_event.dart';
part 'weather_state.dart';
class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
final WeatherRepository _repository;
WeatherBloc({required WeatherRepository repository})
: _repository = repository,
super(const WeatherState()) {
on<WeatherFetchRequested>(_onFetchRequested);
on<WeatherRefreshRequested>(_onRefreshRequested);
}
Future<void> _onFetchRequested(
WeatherFetchRequested event,
Emitter<WeatherState> emit,
) async {
emit(state.copyWith(status: WeatherStatus.loading));
try {
final weather = await _repository.fetchWeather(event.city);
emit(state.copyWith(
status: WeatherStatus.success,
weather: weather,
));
} catch (e) {
emit(state.copyWith(
status: WeatherStatus.failure,
errorMessage: e.toString(),
));
}
}
Future<void> _onRefreshRequested(
WeatherRefreshRequested event,
Emitter<WeatherState> emit,
) async {
// Silent Refresh: set status to loading, but do not delete existing weather data!
emit(state.copyWith(status: WeatherStatus.loading));
try {
final weather = await _repository.fetchWeather(event.city);
emit(state.copyWith(
status: WeatherStatus.success,
weather: weather,
));
} catch (e) {
emit(state.copyWith(
status: WeatherStatus.failure,
errorMessage: e.toString(),
));
}
}
}
We split this feature layout into:
WeatherPage – Sets up the dependency injection context.
WeatherView – Owns the text controllers and renders states.
WeatherPage
// lib/features/weather/view/weather_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/weather_bloc.dart';
import '../repositories/weather_repository.dart';
import 'weather_view.dart';
class WeatherPage extends StatelessWidget {
const WeatherPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => WeatherBloc(repository: context.read<WeatherRepository>()),
child: const WeatherView(),
);
}
}
WeatherView
// lib/features/weather/view/weather_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/weather_bloc.dart';
class WeatherView extends StatefulWidget {
const WeatherView({super.key});
@override
State<WeatherView> createState() => _WeatherViewState();
}
class _WeatherViewState extends State<WeatherView> {
final _controller = TextEditingController(text: 'London');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Weather BLoC')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'City',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
context.read<WeatherBloc>().add(
WeatherFetchRequested(_controller.text),
);
},
child: const Text('Fetch Weather'),
),
const SizedBox(height: 24),
BlocBuilder<WeatherBloc, WeatherState>(
builder: (context, state) {
// If there is cached or existing weather data, render it.
// We keep displaying it even while a background refresh loads.
if (state.weather != null) {
return Column(
children: [
Text(
state.weather!.city,
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'${state.weather!.tempC.toStringAsFixed(1)} °C',
style: Theme.of(context).textTheme.displaySmall,
),
Text(state.weather!.condition),
// Overlay an activity bar if a background refresh is in progress
if (state.status == WeatherStatus.loading) ...[
const SizedBox(height: 16),
const CircularProgressIndicator(),
],
],
);
}
// If no weather has been fetched yet, render initial/loading/failure views
return switch (state.status) {
WeatherStatus.initial => const Text(
'Enter a city and fetch weather',
),
WeatherStatus.loading => const CircularProgressIndicator(),
WeatherStatus.failure => Text(
state.errorMessage ?? 'An error occurred',
style: const TextStyle(color: Colors.red),
),
WeatherStatus.success => const SizedBox.shrink(),
};
},
),
],
),
),
);
}
}
Wire repositories and observation logic globally inside your main.dart.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'features/weather/repositories/weather_repository.dart';
import 'features/weather/view/weather_page.dart';
void main() {
Bloc.observer = AppBlocObserver();
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return RepositoryProvider(
create: (_) => WeatherRepository(),
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Weather BLoC Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const WeatherPage(),
),
);
}
}
class AppBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
debugPrint(
'[${bloc.runtimeType}] '
'${change.currentState.status} → '
'${change.nextState.status}',
);
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
debugPrint('[${bloc.runtimeType}] ERROR: $error');
super.onError(bloc, error, stackTrace);
}
}
BLoC isn’t popular because it is trendy; it sticks because it makes app behavior
extremely predictable. When the codebase grows, and production issues happen
under pressure, having one place where “what happened” maps strictly to state
output reduces debugging surface area.
- Testability without emulation:
WeatherBloc has no dependencies on the
Flutter UI, so you can test it entirely using standard unit tests or the
bloc_test package.
- Traceability with observers: A custom
BlocObserver logs all transitions
globally.
- Decoupled code ownership: Designers work on the UI matching state
variables, network developers write repositories, and logic developers write
BLoCs.
Shorebird enables Flutter applications to download
code updates on the fly without waiting for store reviews.
The Single State pattern works hand-in-hand with Shorebird:
- Minimized Change blast radius: If you need to fix state mappings or API
endpoints, the code changes are isolated to standard Dart files
(
WeatherBloc or WeatherRepository) rather than UI layout files.
- Stable State Restoration: Because the state structure is single-class
and predictable, patching business logic does not break active UI memory
sessions.